From 77019ec050e22473658590e704c0ba0d4baa8195 Mon Sep 17 00:00:00 2001 From: zyh Date: Mon, 30 Mar 2026 07:14:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=8F=92=E4=BB=B6=E7=8B=AC=E7=AB=8B=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加完整的微信插件测试框架,包含: - 微信插件源码(weixin-plugin/) - TypeScript 类型定义(types.ts) - 插件编译脚本(scripts/build-weixin-plugin.sh) - 简化测试脚本(scripts/test-weixin-simple.js) - 完整测试脚本(scripts/test-weixin-plugin.js) - 项目配置和依赖管理(package.json) - 使用说明文档(README.md) 主要功能: - 支持二维码登录认证 - HTTP 长轮询消息接收 - 文本、图片、视频、文件消息收发 - 自动回复测试 - 运行状态统计 Generated with [Claude Code](https://claude.com/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .gitignore | 28 ++ README.md | 179 ++++++++ package.json | 19 + scripts/build-weixin-plugin.sh | 101 +++++ scripts/test-weixin-plugin.js | 428 ++++++++++++++++++ scripts/test-weixin-simple.js | 296 ++++++++++++ weixin-plugin/types.ts | 64 +++ weixin-plugin/weixin/api/api.ts | 238 ++++++++++ weixin-plugin/weixin/api/config-cache.ts | 79 ++++ weixin-plugin/weixin/api/session-guard.ts | 58 +++ weixin-plugin/weixin/api/types.ts | 220 +++++++++ weixin-plugin/weixin/auth/accounts.ts | 213 +++++++++ weixin-plugin/weixin/auth/login-qr.ts | 328 ++++++++++++++ weixin-plugin/weixin/auth/pairing.ts | 126 ++++++ weixin-plugin/weixin/cdn/aes-ecb.ts | 21 + weixin-plugin/weixin/cdn/cdn-upload.ts | 77 ++++ weixin-plugin/weixin/cdn/cdn-url.ts | 17 + weixin-plugin/weixin/cdn/pic-decrypt.ts | 85 ++++ weixin-plugin/weixin/cdn/upload.ts | 155 +++++++ weixin-plugin/weixin/channel.ts | 81 ++++ weixin-plugin/weixin/config/config-schema.ts | 22 + weixin-plugin/weixin/log-upload.ts | 67 +++ weixin-plugin/weixin/media/media-download.ts | 141 ++++++ weixin-plugin/weixin/media/mime.ts | 76 ++++ weixin-plugin/weixin/media/silk-transcode.ts | 74 +++ weixin-plugin/weixin/messaging/debug-mode.ts | 69 +++ .../weixin/messaging/error-notice.ts | 30 ++ weixin-plugin/weixin/messaging/inbound.ts | 262 +++++++++++ .../weixin/messaging/process-message.ts | 33 ++ weixin-plugin/weixin/messaging/send-media.ts | 72 +++ weixin-plugin/weixin/messaging/send.ts | 209 +++++++++ .../weixin/messaging/slash-commands.ts | 110 +++++ .../weixin/messaging/strip-markdown.ts | 27 ++ weixin-plugin/weixin/monitor/monitor.ts | 158 +++++++ weixin-plugin/weixin/runtime.ts | 22 + weixin-plugin/weixin/storage/state-dir.ts | 15 + weixin-plugin/weixin/storage/sync-buf.ts | 46 ++ weixin-plugin/weixin/util/logger.ts | 141 ++++++ weixin-plugin/weixin/util/random.ts | 17 + weixin-plugin/weixin/util/redact.ts | 54 +++ weixin-plugin/weixin/vendor.d.ts | 25 + weixin-plugin/weixinGateway.ts | 366 +++++++++++++++ 42 files changed, 4849 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100755 scripts/build-weixin-plugin.sh create mode 100644 scripts/test-weixin-plugin.js create mode 100644 scripts/test-weixin-simple.js create mode 100644 weixin-plugin/types.ts create mode 100644 weixin-plugin/weixin/api/api.ts create mode 100644 weixin-plugin/weixin/api/config-cache.ts create mode 100644 weixin-plugin/weixin/api/session-guard.ts create mode 100644 weixin-plugin/weixin/api/types.ts create mode 100644 weixin-plugin/weixin/auth/accounts.ts create mode 100644 weixin-plugin/weixin/auth/login-qr.ts create mode 100644 weixin-plugin/weixin/auth/pairing.ts create mode 100644 weixin-plugin/weixin/cdn/aes-ecb.ts create mode 100644 weixin-plugin/weixin/cdn/cdn-upload.ts create mode 100644 weixin-plugin/weixin/cdn/cdn-url.ts create mode 100644 weixin-plugin/weixin/cdn/pic-decrypt.ts create mode 100644 weixin-plugin/weixin/cdn/upload.ts create mode 100644 weixin-plugin/weixin/channel.ts create mode 100644 weixin-plugin/weixin/config/config-schema.ts create mode 100644 weixin-plugin/weixin/log-upload.ts create mode 100644 weixin-plugin/weixin/media/media-download.ts create mode 100644 weixin-plugin/weixin/media/mime.ts create mode 100644 weixin-plugin/weixin/media/silk-transcode.ts create mode 100644 weixin-plugin/weixin/messaging/debug-mode.ts create mode 100644 weixin-plugin/weixin/messaging/error-notice.ts create mode 100644 weixin-plugin/weixin/messaging/inbound.ts create mode 100644 weixin-plugin/weixin/messaging/process-message.ts create mode 100644 weixin-plugin/weixin/messaging/send-media.ts create mode 100644 weixin-plugin/weixin/messaging/send.ts create mode 100644 weixin-plugin/weixin/messaging/slash-commands.ts create mode 100644 weixin-plugin/weixin/messaging/strip-markdown.ts create mode 100644 weixin-plugin/weixin/monitor/monitor.ts create mode 100644 weixin-plugin/weixin/runtime.ts create mode 100644 weixin-plugin/weixin/storage/state-dir.ts create mode 100644 weixin-plugin/weixin/storage/sync-buf.ts create mode 100644 weixin-plugin/weixin/util/logger.ts create mode 100644 weixin-plugin/weixin/util/random.ts create mode 100644 weixin-plugin/weixin/util/redact.ts create mode 100644 weixin-plugin/weixin/vendor.d.ts create mode 100644 weixin-plugin/weixinGateway.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56df546 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# 依赖 +node_modules/ + +# 编译输出 +weixin-plugin/dist/ +weixin-plugin/tsconfig.json + +# 测试输出 +*.log +weixin-qrcode.png + +# 编辑器 +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 操作系统 +.DS_Store +Thumbs.db + +# 环境变量 +.env +.env.local + +# 账号数据 +.openclaw/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..38493f4 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# 微信插件独立测试环境 + +这是一个独立的测试环境,用于验证微信插件 (`weixin-plugin/`) 的功能。 + +## 目录结构 + +``` +weixin-plugin-test/ +├── package.json # 项目配置和依赖 +├── weixin-plugin/ # 微信插件源码 +│ ├── types.ts # 类型定义 +│ ├── weixinGateway.ts # 网关入口 +│ ├── weixin/ # 核心实现 +│ └── dist/ # 编译输出(运行 build 后生成) +├── scripts/ # 测试脚本 +│ ├── build-weixin-plugin.sh # 编译脚本 +│ ├── test-weixin-simple.js # 简化测试 +│ └── test-weixin-plugin.js # 完整测试 +└── README.md # 本文档 +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd /root/weixin-plugin-test +npm install +``` + +### 2. 编译插件 + +```bash +npm run build +``` + +### 3. 运行测试 + +**简化测试(推荐先运行):** +```bash +npm run test:simple +``` + +**完整测试(包含消息监听和自动回复):** +```bash +npm run test:full +``` + +## 可用命令 + +| 命令 | 说明 | +|------|------| +| `npm install` | 安装所有依赖 | +| `npm run build` | 编译微信插件 | +| `npm run test:simple` | 运行简化测试 | +| `npm run test:full` | 运行完整测试 | +| `npm run clean` | 清理编译文件 | + +## 测试内容 + +### 简化测试 (test:simple) + +1. ✓ 检查依赖(qrcode 等) +2. ✓ 加载微信插件模块 +3. ✓ 获取登录二维码 +4. ✓ 等待扫码登录 +5. ✓ 启动网关 +6. ✓ 验证连接状态 +7. ✓ 停止网关 + +### 完整测试 (test:full) + +包含简化测试的所有内容,额外提供: + +- 持续监听消息 +- 自动回复收到的消息 +- 显示运行统计信息 +- 按 Ctrl+C 退出 + +## 使用流程 + +1. **安装依赖** + ```bash + npm install + ``` + +2. **编译插件** + ```bash + npm run build + ``` + +3. **运行测试** + ```bash + npm run test:simple + ``` + +4. **扫码登录** + - 二维码会保存到 `weixin-qrcode.png` + - 使用微信扫描二维码 + - 在手机上确认登录 + +5. **查看结果** + - 测试通过会显示成功消息 + - 可以看到账号信息和连接状态 + +## 故障排除 + +### 编译失败 + +```bash +# 清理后重新编译 +npm run clean +npm run build +``` + +### 模块加载失败 + +确保已运行编译脚本: +```bash +npm run build +``` + +### 二维码登录超时 + +- 检查网络连接 +- 确保能访问 `ilinkai.weixin.qq.com` +- 重新运行测试 + +## 依赖说明 + +### 生产依赖 + +- `qrcode` - 二维码生成 + +### 开发依赖 + +- `@types/qrcode` - qrcode 类型定义 +- `typescript` - TypeScript 编译器 + +## 数据存储 + +登录后的账号信息保存在: + +``` +~/.openclaw/openclaw-weixin/accounts/ +├── registered-accounts.json # 账号注册表 +└── {accountId}.json # 账号数据(token 等) +``` + +## 清理数据 + +如需清理测试账号数据: + +```bash +# 删除所有测试账号 +rm -rf ~/.openclaw/openclaw-weixin/ + +# 或手动删除特定账号 +rm ~/.openclaw/openclaw-weixin/accounts/test-account.json +``` + +## 注意事项 + +1. **编译要求**:修改源码后需要重新编译 +2. **网络要求**:需要访问 `ilinkai.weixin.qq.com` +3. **二维码有效期**:5 分钟,过期后会自动刷新(最多 3 次) +4. **自动化限制**:需要手动扫码登录,无法完全自动化 + +## 下一步 + +测试通过后,可以: + +1. 将插件集成到主项目 +2. 开发自定义消息处理逻辑 +3. 添加更多功能(群聊、媒体消息等) + +--- + +**最后更新:** 2026-03-30 diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ec39d9 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "weixin-plugin-test", + "version": "1.0.0", + "description": "Standalone test environment for Weixin plugin", + "type": "module", + "scripts": { + "build": "bash scripts/build-weixin-plugin.sh", + "test:simple": "node scripts/test-weixin-simple.js", + "test:full": "node scripts/test-weixin-plugin.js", + "clean": "rm -rf weixin-plugin/dist weixin-plugin/node_modules weixin-plugin/tsconfig.json" + }, + "dependencies": { + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/qrcode": "^1.5.5", + "typescript": "^5.6.3" + } +} diff --git a/scripts/build-weixin-plugin.sh b/scripts/build-weixin-plugin.sh new file mode 100755 index 0000000..1e856ce --- /dev/null +++ b/scripts/build-weixin-plugin.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# 微信插件 TypeScript 编译脚本 +# 用于快速编译 weixin-plugin 目录下的 TypeScript 文件 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +WEIXIN_PLUGIN_DIR="$PROJECT_ROOT/weixin-plugin" + +echo "微信插件编译脚本" +echo "================" +echo "" + +# 检查依赖 +echo "1. 检查依赖..." + +if ! command -v npx &> /dev/null; then + echo " ✗ npx 未找到" + echo " 请确保 Node.js 和 npm 已正确安装" + exit 1 +fi + +echo " ✓ npx 已找到" + +# 检查是否安装了 typescript +if ! npm list typescript &> /dev/null; then + echo " 正在安装 typescript..." + npm install +fi + +echo " ✓ typescript 已准备就绪" +echo "" + +# 创建临时 tsconfig +echo "2. 创建编译配置..." + +cat > "$WEIXIN_PLUGIN_DIR/tsconfig.json" << 'EOF' +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "strict": false, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": ".", + "declaration": false, + "sourceMap": false + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} +EOF + +echo " ✓ tsconfig.json 已创建" +echo "" + +# 编译 +echo "3. 开始编译..." +echo "" + +cd "$WEIXIN_PLUGIN_DIR" + +# 使用 tsc 编译 +npx tsc + +echo "" +echo " ✓ 编译完成" +echo "" + +# 检查输出 +echo "4. 检查输出文件..." + +JS_COUNT=$(find dist -name "*.js" -type f 2>/dev/null | wc -l) +echo " 生成了 $JS_COUNT 个 JavaScript 文件" + +if [ "$JS_COUNT" -eq 0 ]; then + echo " ✗ 编译可能失败,dist 目录为空" + exit 1 +fi + +echo "" +echo "================" +echo "编译成功!" +echo "" +echo "编译后的文件位于: $WEIXIN_PLUGIN_DIR/dist/" +echo "" +echo "你现在可以运行测试脚本了:" +echo " npm run test:simple" diff --git a/scripts/test-weixin-plugin.js b/scripts/test-weixin-plugin.js new file mode 100644 index 0000000..eda73e1 --- /dev/null +++ b/scripts/test-weixin-plugin.js @@ -0,0 +1,428 @@ +#!/usr/bin/env node + +/** + * 微信插件完整测试脚本 + * + * 功能: + * 1. 二维码登录 + * 2. 启动消息监听 + * 3. 自动回复测试 + * 4. 消息收发验证 + * + * 使用方法: + * node scripts/test-weixin-plugin.js + * 或 + * npm run test:full + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +// 获取项目根目录 +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); + +// 导入微信插件模块 +const weixinPluginPath = path.join(projectRoot, 'weixin-plugin'); + +console.log('='.repeat(60)); +console.log('微信插件完整测试脚本'); +console.log('='.repeat(60)); +console.log(`项目根目录: ${projectRoot}`); +console.log(`插件路径: ${weixinPluginPath}`); +console.log(''); + +// ============================================================================= +// 颜色输出工具 +// ============================================================================= + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', +}; + +function log(color, ...args) { + console.log(color + args.join(' ') + colors.reset); +} + +function logInfo(...args) { + log(colors.cyan, '[INFO]', ...args); +} + +function logSuccess(...args) { + log(colors.green, '[OK]', ...args); +} + +function logError(...args) { + log(colors.red, '[ERROR]', ...args); +} + +function logWarn(...args) { + log(colors.yellow, '[WARN]', ...args); +} + +// ============================================================================= +// 测试配置 +// ============================================================================= + +const TEST_CONFIG = { + // 测试账号ID(留空使用默认) + accountId: process.env.WEIXIN_ACCOUNT_ID || '', + // 登录超时时间(毫秒) + loginTimeout: 5 * 60 * 1000, + // 消息监听超时(毫秒,0 表示不限制) + listenTimeout: 0, + // 是否启用自动回复 + autoReply: true, + // 自动回复前缀 + autoReplyPrefix: '[自动回复] ', + // 调试模式 + debug: true, +}; + +// ============================================================================= +// 导入微信插件模块 +// ============================================================================= + +function resolveModulePath() { + const distPath = path.join(weixinPluginPath, 'dist', 'weixinGateway.js'); + if (fs.existsSync(distPath)) { + return distPath; + } + const srcPath = path.join(weixinPluginPath, 'weixinGateway.ts'); + if (fs.existsSync(srcPath)) { + return srcPath; + } + throw new Error('找不到 weixinGateway 模块文件'); +} + +let WeixinGateway, startWeixinLogin, waitWeixinLogin; + +try { + const modulePath = resolveModulePath(); + const weixinGatewayModule = await import(modulePath); + WeixinGateway = weixinGatewayModule.WeixinGateway; + startWeixinLogin = weixinGatewayModule.startWeixinLogin; + waitWeixinLogin = weixinGatewayModule.waitWeixinLogin; + + logInfo('成功加载微信插件模块'); +} catch (err) { + logError('加载微信插件模块失败:', err.message); + logInfo('请确保已编译插件: npm run build'); + process.exit(1); +} + +// ============================================================================= +// 工具函数 +// ============================================================================= + +/** + * 保存二维码到文件 + */ +async function saveQRCode(qrcodeDataUrl, filename = 'weixin-qrcode.png') { + const matches = qrcodeDataUrl.match(/^data:image\/png;base64,(.+)$/); + if (!matches) { + throw new Error('无效的二维码数据'); + } + + const buffer = Buffer.from(matches[1], 'base64'); + const filepath = path.join(projectRoot, filename); + + fs.writeFileSync(filepath, buffer); + logSuccess(`二维码已保存到: ${filepath}`); + + return filepath; +} + +/** + * 格式化时间戳 + */ +function formatTime(timestamp) { + return new Date(timestamp).toLocaleString('zh-CN'); +} + +/** + * 格式化持续时间 + */ +function formatDuration(ms) { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}小时${minutes % 60}分钟`; + } else if (minutes > 0) { + return `${minutes}分钟${seconds % 60}秒`; + } else { + return `${seconds}秒`; + } +} + +// ============================================================================= +// 测试流程 +// ============================================================================= + +/** + * 步骤 1: 二维码登录 + */ +async function testLogin() { + logInfo('开始二维码登录流程...'); + console.log(''); + + try { + // 1. 获取二维码 + logInfo('正在获取登录二维码...'); + const { sessionKey, qrcodeUrl } = await startWeixinLogin(TEST_CONFIG.accountId || 'test-account'); + + if (!qrcodeUrl) { + throw new Error('获取二维码失败'); + } + + // 2. 保存二维码 + await saveQRCode(qrcodeUrl); + logSuccess('二维码获取成功!'); + console.log(''); + + // 3. 显示二维码 + logInfo('请使用微信扫描以下二维码登录:'); + console.log(''); + console.log(qrcodeUrl); + console.log(''); + + logInfo('等待扫码登录...'); + + // 4. 等待登录 + const loginResult = await waitWeixinLogin(sessionKey, TEST_CONFIG.loginTimeout); + + if (!loginResult.connected) { + throw new Error(loginResult.error || '登录失败'); + } + + logSuccess('登录成功!'); + console.log(''); + logInfo('账号信息:'); + logInfo(` - Account ID: ${loginResult.accountId}`); + logInfo(` - User ID: ${loginResult.userId}`); + logInfo(` - Base URL: ${loginResult.baseUrl}`); + console.log(''); + + return loginResult; + } catch (err) { + logError('登录失败:', err.message); + throw err; + } +} + +/** + * 步骤 2: 启动网关 + */ +async function testGateway() { + logInfo('初始化微信网关...'); + console.log(''); + + const gateway = new WeixinGateway(); + + // 设置消息回调 + let messageCount = 0; + let firstMessageTime = null; + + gateway.setMessageCallback(async (message, replyFn) => { + messageCount++; + + if (!firstMessageTime) { + firstMessageTime = Date.now(); + } + + logSuccess('收到消息!'); + console.log(''); + logInfo('消息详情:'); + logInfo(` - 平台: ${message.platform}`); + logInfo(` - 发送者: ${message.senderId}`); + logInfo(` - 会话: ${message.conversationId}`); + logInfo(` - 类型: ${message.chatType}`); + logInfo(` - 内容: ${message.content}`); + logInfo(` - 时间: ${formatTime(message.timestamp)}`); + console.log(''); + + // 自动回复 + if (TEST_CONFIG.autoReply) { + const replyText = `${TEST_CONFIG.autoReplyPrefix}已收到你的消息:"${message.content}"(第${messageCount}条)`; + logInfo(`发送自动回复: ${replyText}`); + + try { + await replyFn(replyText); + logSuccess('自动回复发送成功'); + } catch (err) { + logError('自动回复发送失败:', err.message); + } + console.log(''); + } + }); + + // 监听连接事件 + gateway.once('connected', () => { + logSuccess('网关已连接!'); + const status = gateway.getStatus(); + logInfo(`账号: ${status.accountId}`); + logInfo(`启动时间: ${formatTime(status.startedAt)}`); + console.log(''); + }); + + gateway.on('error', (error) => { + logError('网关错误:', error.message); + }); + + gateway.on('message', (message) => { + // 消息已在回调中处理 + }); + + // 启动网关 + try { + await gateway.start({ + enabled: true, + debug: TEST_CONFIG.debug, + }); + + // 等待连接 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + gateway.off('connected'); + gateway.off('error'); + reject(new Error('连接超时')); + }, 10000); + + gateway.once('connected', () => { + clearTimeout(timeout); + resolve(); + }); + + gateway.once('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + return gateway; + } catch (err) { + logError('启动网关失败:', err.message); + throw err; + } +} + +/** + * 步骤 3: 消息监听测试 + */ +async function testMessageListening(gateway) { + logInfo('开始监听消息...'); + logInfo('你可以向微信机器人发送消息进行测试'); + logInfo('按 Ctrl+C 退出测试'); + console.log(''); + logInfo('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + + const startTime = Date.now(); + let lastMessageCount = 0; + + // 定时显示统计信息 + const statsInterval = setInterval(() => { + const status = gateway.getStatus(); + const elapsed = Date.now() - startTime; + + console.log(''); + logInfo('━━━━ 运行状态 ━━━━'); + logInfo(`运行时长: ${formatDuration(elapsed)}`); + logInfo(`连接状态: ${status.connected ? '已连接' : '未连接'}`); + logInfo(`入站消息: ${status.lastInboundAt ? `最后于 ${formatTime(status.lastInboundAt)}` : '暂无'}`); + logInfo(`出站消息: ${status.lastOutboundAt ? `最后于 ${formatTime(status.lastOutboundAt)}` : '暂无'}`); + logInfo('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + }, 30000); + + // 等待用户中断 + return new Promise((resolve) => { + process.on('SIGINT', () => { + console.log(''); + logInfo('收到退出信号,正在停止...'); + + clearInterval(statsInterval); + + const elapsed = Date.now() - startTime; + logInfo('━━━━ 测试统计 ━━━━'); + logInfo(`运行时长: ${formatDuration(elapsed)}`); + logInfo(`接收消息数: ${lastMessageCount} 条`); + logInfo('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + + resolve(); + }); + + // 监听消息事件来更新计数 + gateway.on('message', () => { + lastMessageCount++; + }); + }); +} + +/** + * 主测试流程 + */ +async function main() { + const startTime = Date.now(); + + try { + // 步骤 1: 登录 + const loginResult = await testLogin(); + + // 步骤 2: 启动网关 + const gateway = await testGateway(); + + // 步骤 3: 监听消息 + await testMessageListening(gateway); + + // 停止网关 + logInfo('正在停止网关...'); + await gateway.stop(); + logSuccess('网关已停止'); + + // 完成 + const elapsed = Date.now() - startTime; + console.log(''); + logSuccess('测试完成!总耗时:', formatDuration(elapsed)); + + } catch (err) { + console.log(''); + logError('测试失败:', err.message); + + if (TEST_CONFIG.debug) { + console.log(''); + console.error(err); + } + + process.exit(1); + } +} + +// ============================================================================= +// 运行测试 +// ============================================================================= + +console.log('测试配置:'); +console.log(` - 账号ID: ${TEST_CONFIG.accountId || 'test-account (默认)'}`); +console.log(` - 登录超时: ${formatDuration(TEST_CONFIG.loginTimeout)}`); +console.log(` - 自动回复: ${TEST_CONFIG.autoReply ? '启用' : '禁用'}`); +console.log(` - 调试模式: ${TEST_CONFIG.debug ? '启用' : '禁用'}`); +console.log(''); +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +console.log(''); + +main().catch((err) => { + logError('未处理的错误:', err); + process.exit(1); +}); diff --git a/scripts/test-weixin-simple.js b/scripts/test-weixin-simple.js new file mode 100644 index 0000000..46978a2 --- /dev/null +++ b/scripts/test-weixin-simple.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +/** + * 微信插件简化测试脚本 + * + * 这是一个最小化的测试脚本,用于验证微信插件的基本功能: + * 1. 检查依赖和模块加载 + * 2. 二维码登录 + * 3. 消息收发 + * + * 使用方法: + * node scripts/test-weixin-simple.js + * 或 + * npm run test:simple + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const weixinPluginPath = path.join(projectRoot, 'weixin-plugin'); + +console.log('微信插件简化测试'); +console.log('='.repeat(50)); + +// ============================================================================= +// 依赖检查 +// ============================================================================= + +async function checkDependencies() { + console.log('\n1. 检查依赖...'); + + const dependencies = [ + { name: 'qrcode', import: () => import('qrcode') }, + { name: 'fs', import: () => import('fs') }, + { name: 'path', import: () => import('path') }, + { name: 'crypto', import: () => import('crypto') }, + { name: 'os', import: () => import('os') }, + ]; + + const missing = []; + + for (const dep of dependencies) { + try { + await dep.import(); + console.log(` ✓ ${dep.name}`); + } catch { + console.log(` ✗ ${dep.name} (缺失)`); + missing.push(dep.name); + } + } + + if (missing.length > 0) { + console.log(`\n请安装缺失的依赖: npm install ${missing.join(' ')}`); + process.exit(1); + } + + console.log(' 所有依赖检查通过\n'); +} + +// ============================================================================= +// 模块路径解析 +// ============================================================================= + +function resolveModulePath() { + // 优先使用编译后的 dist 目录 + const distPath = path.join(weixinPluginPath, 'dist', 'weixinGateway.js'); + + if (fs.existsSync(distPath)) { + console.log('\n使用编译后的模块:', distPath); + return distPath; + } + + // 回退到 TypeScript 源文件(需要 tsx 或类似工具) + const srcPath = path.join(weixinPluginPath, 'weixinGateway.ts'); + + if (fs.existsSync(srcPath)) { + console.log('\n注意: 发现 TypeScript 源文件'); + console.log(' 建议: 先运行编译脚本'); + console.log(' npm run build\n'); + return srcPath; + } + + throw new Error('找不到 weixinGateway 模块文件'); +} + +// ============================================================================= +// 模块加载测试 +// ============================================================================= + +async function testModuleLoading() { + console.log('2. 测试模块加载...'); + + try { + const modulePath = resolveModulePath(); + console.log(` 加载路径: ${modulePath}`); + + const module = await import(modulePath); + console.log(` ✓ WeixinGateway: ${typeof module.WeixinGateway}`); + console.log(` ✓ startWeixinLogin: ${typeof module.startWeixinLogin}`); + console.log(` ✓ waitWeixinLogin: ${typeof module.waitWeixinLogin}`); + + console.log(' 模块加载成功\n'); + return module; + } catch (err) { + console.log(` ✗ 加载失败: ${err.message}`); + console.log('\n可能的原因:'); + console.log(' 1. TypeScript 文件需要先编译'); + console.log(' 解决方案: npm run build'); + console.log(' 2. 缺少 weixin-plugin/types.ts 文件'); + console.log(' 解决方案: 检查文件是否存在'); + console.log(' 3. 需要安装 tsx 来运行 TypeScript'); + console.log(' 解决方案: npm install -g tsx\n'); + + // 尝试提供更详细的错误信息 + if (err.message.includes('Cannot find module')) { + console.log('详细错误: 模块未找到'); + console.log('请确保已编译微信插件:'); + console.log(' npm run build\n'); + } + + throw err; + } +} + +// ============================================================================= +// 二维码登录测试 +// ============================================================================= + +async function testQRLogin(module) { + console.log('3. 测试二维码登录...'); + + const { startWeixinLogin, waitWeixinLogin } = module; + + console.log(' 正在获取二维码...'); + + try { + const { sessionKey, qrcodeUrl } = await startWeixinLogin('test-account'); + + if (!qrcodeUrl) { + throw new Error('未能获取二维码'); + } + + // 保存二维码 + const matches = qrcodeUrl.match(/^data:image\/png;base64,(.+)$/); + if (matches) { + const buffer = Buffer.from(matches[1], 'base64'); + const qrcodePath = path.join(projectRoot, 'weixin-qrcode.png'); + fs.writeFileSync(qrcodePath, buffer); + console.log(` ✓ 二维码已保存到: ${qrcodePath}`); + } + + console.log(' ✓ 二维码获取成功'); + console.log(` Session Key: ${sessionKey}`); + console.log('\n请使用微信扫描二维码登录'); + console.log('等待登录(最长 5 分钟)...\n'); + + // 等待登录 + const result = await waitWeixinLogin(sessionKey, 5 * 60 * 1000); + + if (!result.connected) { + throw new Error(result.error || '登录失败'); + } + + console.log(' ✓ 登录成功!'); + console.log(` Account ID: ${result.accountId}`); + console.log(` User ID: ${result.userId}`); + console.log(` Base URL: ${result.baseUrl}\n`); + + return result; + } catch (err) { + console.log(` ✗ 登录失败: ${err.message}\n`); + throw err; + } +} + +// ============================================================================= +// 网关启动测试 +// ============================================================================= + +async function testGateway(module) { + console.log('4. 测试网关启动...'); + + const { WeixinGateway } = module; + + try { + const gateway = new WeixinGateway(); + + // 监听事件 + let connected = false; + let hasError = false; + + gateway.once('connected', () => { + connected = true; + console.log(' ✓ 网关已连接'); + }); + + gateway.once('error', (err) => { + hasError = true; + console.log(` ✗ 网关错误: ${err.message}`); + }); + + // 启动网关 + await gateway.start({ enabled: true, debug: true }); + + // 等待连接 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!connected) { + reject(new Error('连接超时')); + } else { + resolve(); + } + }, 10000); + + gateway.once('connected', () => { + clearTimeout(timeout); + resolve(); + }); + + gateway.once('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + if (hasError) { + throw new Error('网关启动时发生错误'); + } + + // 检查状态 + const status = gateway.getStatus(); + console.log(` 连接状态: ${status.connected ? '已连接' : '未连接'}`); + console.log(` 账号ID: ${status.accountId}`); + console.log(` 启动时间: ${status.startedAt ? new Date(status.startedAt).toLocaleString() : 'N/A'}\n`); + + // 停止网关 + await gateway.stop(); + console.log(' ✓ 网关已停止\n'); + + return true; + } catch (err) { + console.log(` ✗ 网关测试失败: ${err.message}\n`); + throw err; + } +} + +// ============================================================================= +// 主流程 +// ============================================================================= + +async function main() { + const startTime = Date.now(); + + try { + // 1. 检查依赖 + await checkDependencies(); + + // 2. 测试模块加载 + const module = await testModuleLoading(); + + // 3. 测试二维码登录 + await testQRLogin(module); + + // 4. 测试网关 + await testGateway(module); + + // 完成 + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log('='.repeat(50)); + console.log(`✓ 所有测试通过!耗时 ${elapsed} 秒`); + console.log('\n微信插件基本功能验证通过,可以正常使用。'); + console.log('如需完整测试(包括消息收发),请运行:'); + console.log(' npm run test:full\n'); + + } catch (err) { + console.log('='.repeat(50)); + console.log('✗ 测试失败'); + console.log(`错误: ${err.message}\n`); + + if (err.stack) { + console.log('详细错误信息:'); + console.log(err.stack); + } + + process.exit(1); + } +} + +// 执行 +main().catch((err) => { + console.error('未处理的错误:', err); + process.exit(1); +}); diff --git a/weixin-plugin/types.ts b/weixin-plugin/types.ts new file mode 100644 index 0000000..9d60d26 --- /dev/null +++ b/weixin-plugin/types.ts @@ -0,0 +1,64 @@ +/** + * Weixin Plugin Type Definitions + * Types for Weixin personal account integration + */ + +// ==================== Weixin Types ==================== + +export interface WeixinConfig { + enabled: boolean; + debug?: boolean; +} + +export interface WeixinGatewayStatus { + connected: boolean; + startedAt: number | null; + lastError: string | null; + accountId: string | null; + lastInboundAt: number | null; + lastOutboundAt: number | null; +} + +// ==================== Common IM Types (reused from main project) ==================== + +export interface IMMessage { + platform: string; + messageId: string; + conversationId: string; + senderId: string; + senderName?: string; + groupName?: string; + content: string; + chatType: 'direct' | 'group'; + chatSubType?: string; + timestamp: number; + attachments?: IMMediaAttachment[]; + mediaGroupId?: string; +} + +export interface IMMediaAttachment { + type: 'image' | 'video' | 'audio' | 'voice' | 'document' | 'sticker'; + localPath: string; + mimeType: string; + fileName?: string; + fileSize?: number; + width?: number; + height?: number; + duration?: number; +} + +// ==================== Default Values ==================== + +export const DEFAULT_WEIXIN_CONFIG: WeixinConfig = { + enabled: false, + debug: true, +}; + +export const DEFAULT_WEIXIN_STATUS: WeixinGatewayStatus = { + connected: false, + startedAt: null, + lastError: null, + accountId: null, + lastInboundAt: null, + lastOutboundAt: null, +}; diff --git a/weixin-plugin/weixin/api/api.ts b/weixin-plugin/weixin/api/api.ts new file mode 100644 index 0000000..7b55328 --- /dev/null +++ b/weixin-plugin/weixin/api/api.ts @@ -0,0 +1,238 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { loadConfigRouteTag } from "../auth/accounts.js"; +import { logger } from "../util/logger.js"; +import { redactBody, redactUrl } from "../util/redact.js"; + +import type { + BaseInfo, + GetUploadUrlReq, + GetUploadUrlResp, + GetUpdatesReq, + GetUpdatesResp, + SendMessageReq, + SendTypingReq, + GetConfigResp, +} from "./types.js"; + +export type WeixinApiOptions = { + baseUrl: string; + token?: string; + timeoutMs?: number; + /** Long-poll timeout for getUpdates (server may hold the request up to this). */ + longPollTimeoutMs?: number; +}; + +// --------------------------------------------------------------------------- +// BaseInfo — attached to every outgoing CGI request +// --------------------------------------------------------------------------- + +function readChannelVersion(): string { + try { + const pkgPath = path.resolve(__dirname, "..", "..", "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string }; + return pkg.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +const CHANNEL_VERSION = readChannelVersion(); + +/** Build the `base_info` payload included in every API request. */ +export function buildBaseInfo(): BaseInfo { + return { channel_version: CHANNEL_VERSION }; +} + +/** Default timeout for long-poll getUpdates requests. */ +const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; +/** Default timeout for regular API requests (sendMessage, getUploadUrl). */ +const DEFAULT_API_TIMEOUT_MS = 15_000; +/** Default timeout for lightweight API requests (getConfig, sendTyping). */ +const DEFAULT_CONFIG_TIMEOUT_MS = 10_000; + +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */ +function randomWechatUin(): string { + const uint32 = crypto.randomBytes(4).readUInt32BE(0); + return Buffer.from(String(uint32), "utf-8").toString("base64"); +} + +function buildHeaders(opts: { token?: string; body: string }): Record { + const headers: Record = { + "Content-Type": "application/json", + AuthorizationType: "ilink_bot_token", + "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")), + "X-WECHAT-UIN": randomWechatUin(), + }; + if (opts.token?.trim()) { + headers.Authorization = `Bearer ${opts.token.trim()}`; + } + const routeTag = loadConfigRouteTag(); + if (routeTag) { + headers.SKRouteTag = routeTag; + } + logger.debug( + `requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`, + ); + return headers; +} + +/** + * Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort. + * Returns the raw response text on success; throws on HTTP error or timeout. + */ +async function apiFetch(params: { + baseUrl: string; + endpoint: string; + body: string; + token?: string; + timeoutMs: number; + label: string; +}): Promise { + const base = ensureTrailingSlash(params.baseUrl); + const url = new URL(params.endpoint, base); + const hdrs = buildHeaders({ token: params.token, body: params.body }); + logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`); + + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), params.timeoutMs); + try { + const res = await fetch(url.toString(), { + method: "POST", + headers: hdrs, + body: params.body, + signal: controller.signal, + }); + clearTimeout(t); + const rawText = await res.text(); + logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`); + if (!res.ok) { + throw new Error(`${params.label} ${res.status}: ${rawText}`); + } + return rawText; + } catch (err) { + clearTimeout(t); + throw err; + } +} + +/** + * Long-poll getUpdates. Server should hold the request until new messages or timeout. + * + * On client-side timeout (no server response within timeoutMs), returns an empty response + * with ret=0 so the caller can simply retry. This is normal for long-poll. + */ +export async function getUpdates( + params: GetUpdatesReq & { + baseUrl: string; + token?: string; + timeoutMs?: number; + }, +): Promise { + const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS; + try { + const rawText = await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/getupdates", + body: JSON.stringify({ + get_updates_buf: params.get_updates_buf ?? "", + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: timeout, + label: "getUpdates", + }); + const resp: GetUpdatesResp = JSON.parse(rawText); + return resp; + } catch (err) { + // Long-poll timeout is normal; return empty response so caller can retry + if (err instanceof Error && err.name === "AbortError") { + logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`); + return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf }; + } + throw err; + } +} + +/** Get a pre-signed CDN upload URL for a file. */ +export async function getUploadUrl( + params: GetUploadUrlReq & WeixinApiOptions, +): Promise { + const rawText = await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/getuploadurl", + body: JSON.stringify({ + filekey: params.filekey, + media_type: params.media_type, + to_user_id: params.to_user_id, + rawsize: params.rawsize, + rawfilemd5: params.rawfilemd5, + filesize: params.filesize, + thumb_rawsize: params.thumb_rawsize, + thumb_rawfilemd5: params.thumb_rawfilemd5, + thumb_filesize: params.thumb_filesize, + no_need_thumb: params.no_need_thumb, + aeskey: params.aeskey, + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, + label: "getUploadUrl", + }); + const resp: GetUploadUrlResp = JSON.parse(rawText); + return resp; +} + +/** Send a single message downstream. */ +export async function sendMessage( + params: WeixinApiOptions & { body: SendMessageReq }, +): Promise { + await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/sendmessage", + body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, + label: "sendMessage", + }); +} + +/** Fetch bot config (includes typing_ticket) for a given user. */ +export async function getConfig( + params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string }, +): Promise { + const rawText = await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/getconfig", + body: JSON.stringify({ + ilink_user_id: params.ilinkUserId, + context_token: params.contextToken, + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, + label: "getConfig", + }); + const resp: GetConfigResp = JSON.parse(rawText); + return resp; +} + +/** Send a typing indicator to a user. */ +export async function sendTyping( + params: WeixinApiOptions & { body: SendTypingReq }, +): Promise { + await apiFetch({ + baseUrl: params.baseUrl, + endpoint: "ilink/bot/sendtyping", + body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }), + token: params.token, + timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, + label: "sendTyping", + }); +} diff --git a/weixin-plugin/weixin/api/config-cache.ts b/weixin-plugin/weixin/api/config-cache.ts new file mode 100644 index 0000000..cad9d0f --- /dev/null +++ b/weixin-plugin/weixin/api/config-cache.ts @@ -0,0 +1,79 @@ +import { getConfig } from "./api.js"; + +/** Subset of getConfig fields that we actually need; add new fields here as needed. */ +export interface CachedConfig { + typingTicket: string; +} + +const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000; +const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000; + +interface ConfigCacheEntry { + config: CachedConfig; + everSucceeded: boolean; + nextFetchAt: number; + retryDelayMs: number; +} + +/** + * Per-user getConfig cache with periodic random refresh (within 24h) and + * exponential-backoff retry (up to 1h) on failure. + */ +export class WeixinConfigManager { + private cache = new Map(); + + constructor( + private apiOpts: { baseUrl: string; token?: string }, + private log: (msg: string) => void, + ) {} + + async getForUser(userId: string, contextToken?: string): Promise { + const now = Date.now(); + const entry = this.cache.get(userId); + const shouldFetch = !entry || now >= entry.nextFetchAt; + + if (shouldFetch) { + let fetchOk = false; + try { + const resp = await getConfig({ + baseUrl: this.apiOpts.baseUrl, + token: this.apiOpts.token, + ilinkUserId: userId, + contextToken, + }); + if (resp.ret === 0) { + this.cache.set(userId, { + config: { typingTicket: resp.typing_ticket ?? "" }, + everSucceeded: true, + nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS, + retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS, + }); + this.log( + `[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`, + ); + fetchOk = true; + } + } catch (err) { + this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`); + } + if (!fetchOk) { + const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS; + const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS); + if (entry) { + entry.nextFetchAt = now + nextDelay; + entry.retryDelayMs = nextDelay; + } else { + this.cache.set(userId, { + config: { typingTicket: "" }, + everSucceeded: false, + nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS, + retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS, + }); + } + } + } + + return this.cache.get(userId)?.config ?? { typingTicket: "" }; + } +} diff --git a/weixin-plugin/weixin/api/session-guard.ts b/weixin-plugin/weixin/api/session-guard.ts new file mode 100644 index 0000000..e31094c --- /dev/null +++ b/weixin-plugin/weixin/api/session-guard.ts @@ -0,0 +1,58 @@ +import { logger } from "../util/logger.js"; + +const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000; + +/** Error code returned by the server when the bot session has expired. */ +export const SESSION_EXPIRED_ERRCODE = -14; + +const pauseUntilMap = new Map(); + +/** Pause all inbound/outbound API calls for `accountId` for one hour. */ +export function pauseSession(accountId: string): void { + const until = Date.now() + SESSION_PAUSE_DURATION_MS; + pauseUntilMap.set(accountId, until); + logger.info( + `session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`, + ); +} + +/** Returns `true` when the bot is still within its one-hour cooldown window. */ +export function isSessionPaused(accountId: string): boolean { + const until = pauseUntilMap.get(accountId); + if (until === undefined) return false; + if (Date.now() >= until) { + pauseUntilMap.delete(accountId); + return false; + } + return true; +} + +/** Milliseconds remaining until the pause expires (0 when not paused). */ +export function getRemainingPauseMs(accountId: string): number { + const until = pauseUntilMap.get(accountId); + if (until === undefined) return 0; + const remaining = until - Date.now(); + if (remaining <= 0) { + pauseUntilMap.delete(accountId); + return 0; + } + return remaining; +} + +/** Throw if the session is currently paused. Call before any API request. */ +export function assertSessionActive(accountId: string): void { + if (isSessionPaused(accountId)) { + const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000); + throw new Error( + `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`, + ); + } +} + +/** + * Reset internal state — only for tests. + * @internal + */ +export function _resetForTest(): void { + pauseUntilMap.clear(); +} diff --git a/weixin-plugin/weixin/api/types.ts b/weixin-plugin/weixin/api/types.ts new file mode 100644 index 0000000..a910f26 --- /dev/null +++ b/weixin-plugin/weixin/api/types.ts @@ -0,0 +1,220 @@ +/** + * Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq). + * API uses JSON over HTTP; bytes fields are base64 strings in JSON. + */ + +/** Common request metadata attached to every CGI request. */ +export interface BaseInfo { + channel_version?: string; +} + +/** proto: UploadMediaType */ +export const UploadMediaType = { + IMAGE: 1, + VIDEO: 2, + FILE: 3, + VOICE: 4, +} as const; + +export interface GetUploadUrlReq { + filekey?: string; + /** proto field 2: media_type, see UploadMediaType */ + media_type?: number; + to_user_id?: string; + /** 原文件明文大小 */ + rawsize?: number; + /** 原文件明文 MD5 */ + rawfilemd5?: string; + /** 原文件密文大小(AES-128-ECB 加密后) */ + filesize?: number; + /** 缩略图明文大小(IMAGE/VIDEO 时必填) */ + thumb_rawsize?: number; + /** 缩略图明文 MD5(IMAGE/VIDEO 时必填) */ + thumb_rawfilemd5?: string; + /** 缩略图密文大小(IMAGE/VIDEO 时必填) */ + thumb_filesize?: number; + /** 不需要缩略图上传 URL,默认 false */ + no_need_thumb?: boolean; + /** 加密 key */ + aeskey?: string; +} + +export interface GetUploadUrlResp { + /** 原图上传加密参数 */ + upload_param?: string; + /** 缩略图上传加密参数,无缩略图时为空 */ + thumb_upload_param?: string; +} + +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const; + +export const MessageItemType = { + NONE: 0, + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const; + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const; + +export interface TextItem { + text?: string; +} + +/** CDN media reference; aes_key is base64-encoded bytes in JSON. */ +export interface CDNMedia { + encrypt_query_param?: string; + aes_key?: string; + /** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */ + encrypt_type?: number; +} + +export interface ImageItem { + /** 原图 CDN 引用 */ + media?: CDNMedia; + /** 缩略图 CDN 引用 */ + thumb_media?: CDNMedia; + /** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */ + aeskey?: string; + url?: string; + mid_size?: number; + thumb_size?: number; + thumb_height?: number; + thumb_width?: number; + hd_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + /** 语音编码类型:1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */ + encode_type?: number; + bits_per_sample?: number; + /** 采样率 (Hz) */ + sample_rate?: number; + /** 语音长度 (毫秒) */ + playtime?: number; + /** 语音转文字内容 */ + text?: string; +} + +export interface FileItem { + media?: CDNMedia; + file_name?: string; + md5?: string; + len?: string; +} + +export interface VideoItem { + media?: CDNMedia; + video_size?: number; + play_length?: number; + video_md5?: string; + thumb_media?: CDNMedia; + thumb_size?: number; + thumb_height?: number; + thumb_width?: number; +} + +export interface RefMessage { + message_item?: MessageItem; + title?: string; // 摘要 +} + +export interface MessageItem { + type?: number; + create_time_ms?: number; + update_time_ms?: number; + is_completed?: boolean; + msg_id?: string; + ref_msg?: RefMessage; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; +} + +/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */ +export interface WeixinMessage { + seq?: number; + message_id?: number; + from_user_id?: string; + to_user_id?: string; + client_id?: string; + create_time_ms?: number; + update_time_ms?: number; + delete_time_ms?: number; + session_id?: string; + group_id?: string; + message_type?: number; + message_state?: number; + item_list?: MessageItem[]; + context_token?: string; +} + +/** GetUpdates request: bytes fields are base64 strings in JSON. */ +export interface GetUpdatesReq { + /** @deprecated compat only, will be removed */ + sync_buf?: string; + /** Full context buf cached locally; send "" when none (first request or after reset). */ + get_updates_buf?: string; +} + +/** GetUpdates response: bytes fields are base64 strings in JSON. */ +export interface GetUpdatesResp { + ret?: number; + /** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */ + errcode?: number; + errmsg?: string; + msgs?: WeixinMessage[]; + /** @deprecated compat only */ + sync_buf?: string; + /** Full context buf to cache locally and send on next request. */ + get_updates_buf?: string; + /** Server-suggested timeout (ms) for the next getUpdates long-poll. */ + longpolling_timeout_ms?: number; +} + +/** SendMessage request: wraps a single WeixinMessage. */ +export interface SendMessageReq { + msg?: WeixinMessage; +} + +export type SendMessageResp = object; + +/** Typing status: 1 = typing (default), 2 = cancel typing. */ +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +/** SendTyping request: send a typing indicator to a user. */ +export interface SendTypingReq { + ilink_user_id?: string; + typing_ticket?: string; + /** 1=typing (default), 2=cancel typing */ + status?: number; +} + +export interface SendTypingResp { + ret?: number; + errmsg?: string; +} + +/** GetConfig response: bot config including typing_ticket. */ +export interface GetConfigResp { + ret?: number; + errmsg?: string; + /** Base64-encoded typing ticket for sendTyping. */ + typing_ticket?: string; +} diff --git a/weixin-plugin/weixin/auth/accounts.ts b/weixin-plugin/weixin/auth/accounts.ts new file mode 100644 index 0000000..94e00de --- /dev/null +++ b/weixin-plugin/weixin/auth/accounts.ts @@ -0,0 +1,213 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"; +export const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"; + +function resolveWeixinStateDir(): string { + return path.join(resolveStateDir(), "openclaw-weixin"); +} + +function resolveWeixinAccountsDir(): string { + return path.join(resolveWeixinStateDir(), "accounts"); +} + +function resolveAccountRegistryPath(): string { + return path.join(resolveWeixinStateDir(), "accounts", "registered-accounts.json"); +} + +function resolveStateDir(): string { + return ( + process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw") + ); +} + +export interface WeixinAccountData { + token?: string; + baseUrl?: string; + cdnBaseUrl?: string; + userId?: string; + name?: string; + enabled?: boolean; + savedAt?: string; +} + +export type ResolvedWeixinAccount = { + accountId: string; + name: string; + enabled: boolean; + configured: boolean; + token?: string; + baseUrl: string; + cdnBaseUrl: string; + userId?: string; +}; + +function deriveRawAccountId(normalizedId: string): string | undefined { + if (normalizedId.endsWith("-im-bot")) { + return `${normalizedId.slice(0, -7)}@im.bot`; + } + if (normalizedId.endsWith("-im-wechat")) { + return `${normalizedId.slice(0, -10)}@im.wechat`; + } + return undefined; +} + +function normalizeAccountId(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("invalid account id"); + return trimmed.replace(/[@./\\:*?"<>|]/g, "_").replace(/\.\./g, "_"); +} + +function resolveAccountPath(accountId: string): string { + return path.join(resolveWeixinAccountsDir(), `${accountId}.json`); +} + +export function listWeixinAccountIds(_cfg: any): string[] { + const filePath = resolveAccountRegistryPath(); + try { + if (!fs.existsSync(filePath)) return []; + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as { accounts?: string[] }; + return parsed.accounts ?? []; + } catch { + return []; + } +} + +export function registerWeixinAccountId(accountId: string): void { + const filePath = resolveAccountRegistryPath(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + let accounts: string[] = []; + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as { accounts?: string[] }; + accounts = parsed.accounts ?? []; + } + } catch { /* ignore */ } + if (!accounts.includes(accountId)) { + accounts.push(accountId); + fs.writeFileSync(filePath, JSON.stringify({ accounts }, null, 2), "utf-8"); + } +} + +export function unregisterWeixinAccountId(accountId: string): void { + const filePath = resolveAccountRegistryPath(); + try { + if (!fs.existsSync(filePath)) return; + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as { accounts?: string[] }; + const accounts = (parsed.accounts ?? []).filter((id) => id !== accountId); + fs.writeFileSync(filePath, JSON.stringify({ accounts }, null, 2), "utf-8"); + } catch { /* ignore */ } +} + +export function loadWeixinAccount(accountId: string): WeixinAccountData | null { + const primaryPath = resolveAccountPath(accountId); + if (fs.existsSync(primaryPath)) { + try { + return JSON.parse(fs.readFileSync(primaryPath, "utf-8")) as WeixinAccountData; + } catch { /* ignore */ } + } + + const rawId = deriveRawAccountId(accountId); + if (rawId) { + const compatPath = resolveAccountPath(rawId); + if (fs.existsSync(compatPath)) { + try { + return JSON.parse(fs.readFileSync(compatPath, "utf-8")) as WeixinAccountData; + } catch { /* ignore */ } + } + } + + return null; +} + +export function saveWeixinAccount( + accountId: string, + update: { token?: string; baseUrl?: string; userId?: string; name?: string }, +): void { + const dir = resolveWeixinAccountsDir(); + fs.mkdirSync(dir, { recursive: true }); + + const existing = loadWeixinAccount(accountId) ?? {}; + + const token = update.token?.trim() || existing.token; + const baseUrl = update.baseUrl?.trim() || existing.baseUrl; + const userId = update.userId?.trim() || existing.userId; + const name = update.name?.trim() || existing.name; + + const data: WeixinAccountData = { + ...(token ? { token, savedAt: new Date().toISOString() } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(userId ? { userId } : {}), + ...(name ? { name } : {}), + }; + + const filePath = resolveAccountPath(accountId); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); +} + +export function clearWeixinAccount(accountId: string): void { + const dir = resolveWeixinAccountsDir(); + const files = [`${accountId}.json`, `${accountId}.sync.json`, `${accountId}.context-tokens.json`]; + for (const file of files) { + try { + fs.unlinkSync(path.join(dir, file)); + } catch { /* ignore */ } + } +} + +export function resolveWeixinAccount( + _cfg: any, + accountId?: string | null, +): ResolvedWeixinAccount { + const raw = accountId?.trim(); + let targetId: string; + + if (!raw) { + const ids = listWeixinAccountIds(_cfg); + if (ids.length === 0) { + return { + accountId: "", + name: "", + enabled: false, + configured: false, + token: undefined, + baseUrl: DEFAULT_BASE_URL, + cdnBaseUrl: CDN_BASE_URL, + userId: undefined, + }; + } + targetId = ids[0]; + } else { + targetId = normalizeAccountId(raw); + } + + const data = loadWeixinAccount(targetId); + const configured = Boolean(data?.token?.trim()); + + return { + accountId: targetId, + name: data?.name ?? targetId, + enabled: data?.enabled !== false, + configured, + token: data?.token?.trim() || undefined, + baseUrl: data?.baseUrl?.trim() || DEFAULT_BASE_URL, + cdnBaseUrl: data?.cdnBaseUrl?.trim() || CDN_BASE_URL, + userId: data?.userId?.trim() || undefined, + }; +} + +export function loadConfigRouteTag(_accountId?: string): string | null { + return null; +} + +export function triggerWeixinChannelReload(): void { + // No-op in this context +} diff --git a/weixin-plugin/weixin/auth/login-qr.ts b/weixin-plugin/weixin/auth/login-qr.ts new file mode 100644 index 0000000..474be97 --- /dev/null +++ b/weixin-plugin/weixin/auth/login-qr.ts @@ -0,0 +1,328 @@ +import { randomUUID } from "node:crypto"; + +import { loadConfigRouteTag } from "./accounts.js"; +import { logger } from "../util/logger.js"; +import { redactToken } from "../util/redact.js"; + +type ActiveLogin = { + sessionKey: string; + id: string; + qrcode: string; + qrcodeUrl: string; + startedAt: number; + botToken?: string; + status?: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect"; + error?: string; + currentApiBaseUrl?: string; +}; + +const ACTIVE_LOGIN_TTL_MS = 5 * 60_000; +const GET_QRCODE_TIMEOUT_MS = 5_000; +const QR_LONG_POLL_TIMEOUT_MS = 35_000; + +export const DEFAULT_ILINK_BOT_TYPE = "3"; + +const FIXED_BASE_URL = "https://ilinkai.weixin.qq.com"; + +const activeLogins = new Map(); + +interface StatusResponse { + status: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect"; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; + redirect_host?: string; +} + +function isLoginFresh(login: ActiveLogin): boolean { + return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS; +} + +function purgeExpiredLogins(): void { + for (const [id, login] of activeLogins) { + if (!isLoginFresh(login)) { + activeLogins.delete(id); + } + } +} + +async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<{ qrcode: string; qrcodeUrl: string; qrcodeDataUrl: string }> { + const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`; + const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base); + + const headers: Record = {}; + const routeTag = loadConfigRouteTag(); + if (routeTag) { + headers.SKRouteTag = routeTag; + } + + logger.info(`Fetching QR code from: ${url.toString()}`); + + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), GET_QRCODE_TIMEOUT_MS); + try { + const response = await fetch(url.toString(), { headers, signal: controller.signal }); + clearTimeout(t); + if (!response.ok) { + const body = await response.text().catch(() => "(unreadable)"); + throw new Error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`); + } + const resp = await response.json() as { qrcode: string; qrcode_img_content: string }; + logger.info(`QR code received, qrcode=${redactToken(resp.qrcode)}`); + + const QRCode = await import("qrcode"); + const qrcodeDataUrl = await QRCode.toDataURL(resp.qrcode_img_content, { + errorCorrectionLevel: "M", + margin: 2, + width: 256, + color: { dark: "#000000", light: "#ffffff" }, + }); + + return { qrcode: resp.qrcode, qrcodeUrl: resp.qrcode_img_content, qrcodeDataUrl }; + } catch (err) { + clearTimeout(t); + throw err; + } +} + +async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise { + const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`; + const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base); + + const headers: Record = { + "iLink-App-ClientVersion": "1", + }; + const routeTag = loadConfigRouteTag(); + if (routeTag) { + headers.SKRouteTag = routeTag; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS); + try { + const response = await fetch(url.toString(), { headers, signal: controller.signal }); + clearTimeout(timer); + if (!response.ok) { + throw new Error(`QR status poll failed: ${response.status}`); + } + const rawText = await response.text(); + logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`); + return JSON.parse(rawText) as StatusResponse; + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && err.name === "AbortError") { + logger.debug(`pollQRStatus: client-side timeout, returning wait`); + return { status: "wait" }; + } + logger.warn(`pollQRStatus: network error, will retry: ${String(err)}`); + return { status: "wait" }; + } +} + +export type WeixinQrStartResult = { + qrcodeUrl?: string; + message: string; + sessionKey: string; +}; + +export type WeixinQrWaitResult = { + connected: boolean; + botToken?: string; + accountId?: string; + baseUrl?: string; + userId?: string; + message: string; +}; + +export async function startWeixinLoginWithQr(opts: { + verbose?: boolean; + timeoutMs?: number; + force?: boolean; + accountId?: string; + apiBaseUrl: string; + botType?: string; +}): Promise { + const sessionKey = opts.accountId || randomUUID(); + + purgeExpiredLogins(); + + const existing = activeLogins.get(sessionKey); + if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) { + return { + qrcodeUrl: existing.qrcodeUrl, + message: "二维码已就绪,请使用微信扫描。", + sessionKey, + }; + } + + try { + const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE; + logger.info(`Starting Weixin login with bot_type=${botType} baseUrl=${FIXED_BASE_URL}`); + + const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType); + logger.info(`二维码获取成功`); + + const login: ActiveLogin = { + sessionKey, + id: randomUUID(), + qrcode: qrResponse.qrcode, + qrcodeUrl: qrResponse.qrcodeDataUrl, + startedAt: Date.now(), + currentApiBaseUrl: FIXED_BASE_URL, + }; + + activeLogins.set(sessionKey, login); + + return { + qrcodeUrl: qrResponse.qrcodeDataUrl, + message: "使用微信扫描以下二维码,以完成连接。", + sessionKey, + }; + } catch (err) { + logger.error(`Failed to start Weixin login: ${String(err)}`); + return { + message: `Failed to start login: ${String(err)}`, + sessionKey, + }; + } +} + +const MAX_QR_REFRESH_COUNT = 3; + +export async function waitForWeixinLogin(opts: { + timeoutMs?: number; + verbose?: boolean; + sessionKey: string; + apiBaseUrl: string; + botType?: string; +}): Promise { + const activeLogin = activeLogins.get(opts.sessionKey); + + if (!activeLogin) { + logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`); + return { + connected: false, + message: "当前没有进行中的登录,请先发起登录。", + }; + } + + if (!isLoginFresh(activeLogin)) { + logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: "二维码已过期,请重新生成。", + }; + } + + const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000); + const deadline = Date.now() + timeoutMs; + let scannedPrinted = false; + let qrRefreshCount = 1; + + logger.info("Starting to poll QR code status..."); + + while (Date.now() < deadline) { + try { + const currentBaseUrl = activeLogin.currentApiBaseUrl ?? FIXED_BASE_URL; + const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode); + activeLogin.status = statusResponse.status; + + switch (statusResponse.status) { + case "wait": + if (opts.verbose) { + process.stdout.write("."); + } + break; + case "scaned": + if (!scannedPrinted) { + process.stdout.write("\n👀 已扫码,在微信继续操作...\n"); + scannedPrinted = true; + } + break; + case "expired": { + qrRefreshCount++; + if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { + logger.warn(`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: "登录超时:二维码多次过期,请重新开始登录流程。", + }; + } + + process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`); + logger.info(`waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`); + + try { + const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE; + const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType); + activeLogin.qrcode = qrResponse.qrcode; + activeLogin.qrcodeUrl = qrResponse.qrcodeDataUrl; + activeLogin.startedAt = Date.now(); + scannedPrinted = false; + logger.info(`waitForWeixinLogin: new QR code obtained`); + } catch (refreshErr) { + logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: `刷新二维码失败: ${String(refreshErr)}`, + }; + } + break; + } + case "scaned_but_redirect": { + const redirectHost = statusResponse.redirect_host; + if (redirectHost) { + const newBaseUrl = `https://${redirectHost}`; + activeLogin.currentApiBaseUrl = newBaseUrl; + logger.info(`waitForWeixinLogin: IDC redirect, switching to ${redirectHost}`); + } + break; + } + case "confirmed": { + if (!statusResponse.ilink_bot_id) { + activeLogins.delete(opts.sessionKey); + logger.error("Login confirmed but ilink_bot_id missing"); + return { + connected: false, + message: "登录失败:服务器未返回 ilink_bot_id。", + }; + } + + activeLogin.botToken = statusResponse.bot_token; + activeLogins.delete(opts.sessionKey); + + logger.info(`✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`); + + return { + connected: true, + botToken: statusResponse.bot_token, + accountId: statusResponse.ilink_bot_id, + baseUrl: statusResponse.baseurl, + userId: statusResponse.ilink_user_id, + message: "✅ 与微信连接成功!", + }; + } + } + } catch (err) { + logger.error(`Error polling QR status: ${String(err)}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: `Login failed: ${String(err)}`, + }; + } + + await new Promise((r) => setTimeout(r, 1000)); + } + + logger.warn(`waitForWeixinLogin: timed out sessionKey=${opts.sessionKey}`); + activeLogins.delete(opts.sessionKey); + return { + connected: false, + message: "登录超时,请重试。", + }; +} diff --git a/weixin-plugin/weixin/auth/pairing.ts b/weixin-plugin/weixin/auth/pairing.ts new file mode 100644 index 0000000..a0c0d9e --- /dev/null +++ b/weixin-plugin/weixin/auth/pairing.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "../storage/state-dir.js"; +import { logger } from "../util/logger.js"; + +function resolveCredentialsDir(): string { + const override = process.env.OPENCLAW_OAUTH_DIR?.trim(); + if (override) return override; + return path.join(resolveStateDir(), "credentials"); +} + +function safeKey(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) throw new Error("invalid key for allowFrom path"); + const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); + if (!safe || safe === "_") throw new Error("invalid key for allowFrom path"); + return safe; +} + +export function resolveFrameworkAllowFromPath(accountId: string): string { + const base = safeKey("openclaw-weixin"); + const safeAccount = safeKey(accountId); + return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`); +} + +type AllowFromFileContent = { + version: number; + allowFrom: string[]; +}; + +export function readFrameworkAllowFromList(accountId: string): string[] { + const filePath = resolveFrameworkAllowFromPath(accountId); + try { + if (!fs.existsSync(filePath)) return []; + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as AllowFromFileContent; + if (Array.isArray(parsed.allowFrom)) { + return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== ""); + } + } catch { + // best-effort + } + return []; +} + +async function withFileLock( + filePath: string, + _options: { retries: { retries: number; factor: number; minTimeout: number; maxTimeout: number }; stale: number }, + fn: () => Promise +): Promise { + const lockPath = `${filePath}.lock`; + const maxRetries = 3; + let attempt = 0; + + while (attempt < maxRetries) { + try { + const dir = path.dirname(lockPath); + fs.mkdirSync(dir, { recursive: true }); + + const lockContent = JSON.stringify({ pid: process.pid, time: Date.now() }); + fs.writeFileSync(lockPath, lockContent, { flag: "wx" }); + const result = await fn(); + fs.unlinkSync(lockPath); + return result; + } catch (err: any) { + if (err.code === "EEXIST") { + attempt++; + if (attempt >= maxRetries) break; + await new Promise((r) => setTimeout(r, 100 * attempt)); + } else { + throw err; + } + } + } + + return await fn(); +} + +export async function registerUserInFrameworkStore(params: { + accountId: string; + userId: string; +}): Promise<{ changed: boolean }> { + const { accountId, userId } = params; + const trimmedUserId = userId.trim(); + if (!trimmedUserId) return { changed: false }; + + const filePath = resolveFrameworkAllowFromPath(accountId); + + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + if (!fs.existsSync(filePath)) { + const initial: AllowFromFileContent = { version: 1, allowFrom: [] }; + fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8"); + } + + const LOCK_OPTIONS = { + retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 }, + stale: 10_000, + }; + + return await withFileLock(filePath, LOCK_OPTIONS, async () => { + let content: AllowFromFileContent = { version: 1, allowFrom: [] }; + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as AllowFromFileContent; + if (Array.isArray(parsed.allowFrom)) { + content = parsed; + } + } catch { + // If read/parse fails, start fresh + } + + if (content.allowFrom.includes(trimmedUserId)) { + return { changed: false }; + } + + content.allowFrom.push(trimmedUserId); + fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8"); + logger.info( + `registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`, + ); + return { changed: true }; + }); +} \ No newline at end of file diff --git a/weixin-plugin/weixin/cdn/aes-ecb.ts b/weixin-plugin/weixin/cdn/aes-ecb.ts new file mode 100644 index 0000000..1a97743 --- /dev/null +++ b/weixin-plugin/weixin/cdn/aes-ecb.ts @@ -0,0 +1,21 @@ +/** + * Shared AES-128-ECB crypto utilities for CDN upload and download. + */ +import { createCipheriv, createDecipheriv } from "node:crypto"; + +/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */ +export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer { + const cipher = createCipheriv("aes-128-ecb", key, null); + return Buffer.concat([cipher.update(plaintext), cipher.final()]); +} + +/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */ +export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv("aes-128-ecb", key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */ +export function aesEcbPaddedSize(plaintextSize: number): number { + return Math.ceil((plaintextSize + 1) / 16) * 16; +} diff --git a/weixin-plugin/weixin/cdn/cdn-upload.ts b/weixin-plugin/weixin/cdn/cdn-upload.ts new file mode 100644 index 0000000..407fad9 --- /dev/null +++ b/weixin-plugin/weixin/cdn/cdn-upload.ts @@ -0,0 +1,77 @@ +import { encryptAesEcb } from "./aes-ecb.js"; +import { buildCdnUploadUrl } from "./cdn-url.js"; +import { logger } from "../util/logger.js"; +import { redactUrl } from "../util/redact.js"; + +/** Maximum retry attempts for CDN upload. */ +const UPLOAD_MAX_RETRIES = 3; + +/** + * Upload one buffer to the Weixin CDN with AES-128-ECB encryption. + * Returns the download encrypted_query_param from the CDN response. + * Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately. + */ +export async function uploadBufferToCdn(params: { + buf: Buffer; + uploadParam: string; + filekey: string; + cdnBaseUrl: string; + label: string; + aeskey: Buffer; +}): Promise<{ downloadParam: string }> { + const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params; + const ciphertext = encryptAesEcb(buf, aeskey); + const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey }); + logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`); + + let downloadParam: string | undefined; + let lastError: unknown; + + for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) { + try { + const res = await fetch(cdnUrl, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: new Uint8Array(ciphertext), + }); + if (res.status >= 400 && res.status < 500) { + const errMsg = res.headers.get("x-error-message") ?? (await res.text()); + logger.error( + `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`, + ); + throw new Error(`CDN upload client error ${res.status}: ${errMsg}`); + } + if (res.status !== 200) { + const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`; + logger.error( + `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`, + ); + throw new Error(`CDN upload server error: ${errMsg}`); + } + downloadParam = res.headers.get("x-encrypted-param") ?? undefined; + if (!downloadParam) { + logger.error( + `${label}: CDN response missing x-encrypted-param header attempt=${attempt}`, + ); + throw new Error("CDN upload response missing x-encrypted-param header"); + } + logger.debug(`${label}: CDN upload success attempt=${attempt}`); + break; + } catch (err) { + lastError = err; + if (err instanceof Error && err.message.includes("client error")) throw err; + if (attempt < UPLOAD_MAX_RETRIES) { + logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`); + } else { + logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`); + } + } + } + + if (!downloadParam) { + throw lastError instanceof Error + ? lastError + : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`); + } + return { downloadParam }; +} diff --git a/weixin-plugin/weixin/cdn/cdn-url.ts b/weixin-plugin/weixin/cdn/cdn-url.ts new file mode 100644 index 0000000..b03d5b0 --- /dev/null +++ b/weixin-plugin/weixin/cdn/cdn-url.ts @@ -0,0 +1,17 @@ +/** + * Unified CDN URL construction for Weixin CDN upload/download. + */ + +/** Build a CDN download URL from encrypt_query_param. */ +export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string { + return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`; +} + +/** Build a CDN upload URL from upload_param and filekey. */ +export function buildCdnUploadUrl(params: { + cdnBaseUrl: string; + uploadParam: string; + filekey: string; +}): string { + return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`; +} diff --git a/weixin-plugin/weixin/cdn/pic-decrypt.ts b/weixin-plugin/weixin/cdn/pic-decrypt.ts new file mode 100644 index 0000000..1fe995c --- /dev/null +++ b/weixin-plugin/weixin/cdn/pic-decrypt.ts @@ -0,0 +1,85 @@ +import { decryptAesEcb } from "./aes-ecb.js"; +import { buildCdnDownloadUrl } from "./cdn-url.js"; +import { logger } from "../util/logger.js"; + +/** + * Download raw bytes from the CDN (no decryption). + */ +async function fetchCdnBytes(url: string, label: string): Promise { + let res: Response; + try { + res = await fetch(url); + } catch (err) { + const cause = + (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)"; + logger.error( + `${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`, + ); + throw err; + } + logger.debug(`${label}: response status=${res.status} ok=${res.ok}`); + if (!res.ok) { + const body = await res.text().catch(() => "(unreadable)"); + const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`; + logger.error(msg); + throw new Error(msg); + } + return Buffer.from(await res.arrayBuffer()); +} + +/** + * Parse CDNMedia.aes_key into a raw 16-byte AES key. + * + * Two encodings are seen in the wild: + * - base64(raw 16 bytes) → images (aes_key from media field) + * - base64(hex string of 16 bytes) → file / voice / video + * + * In the second case, base64-decoding yields 32 ASCII hex chars which must + * then be parsed as hex to recover the actual 16-byte key. + */ +function parseAesKey(aesKeyBase64: string, label: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, "base64"); + if (decoded.length === 16) { + return decoded; + } + if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) { + // hex-encoded key: base64 → hex string → raw bytes + return Buffer.from(decoded.toString("ascii"), "hex"); + } + const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`; + logger.error(msg); + throw new Error(msg); +} + +/** + * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer. + * aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats). + */ +export async function downloadAndDecryptBuffer( + encryptedQueryParam: string, + aesKeyBase64: string, + cdnBaseUrl: string, + label: string, +): Promise { + const key = parseAesKey(aesKeyBase64, label); + const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl); + logger.debug(`${label}: fetching url=${url}`); + const encrypted = await fetchCdnBytes(url, label); + logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`); + const decrypted = decryptAesEcb(encrypted, key); + logger.debug(`${label}: decrypted ${decrypted.length} bytes`); + return decrypted; +} + +/** + * Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer. + */ +export async function downloadPlainCdnBuffer( + encryptedQueryParam: string, + cdnBaseUrl: string, + label: string, +): Promise { + const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl); + logger.debug(`${label}: fetching url=${url}`); + return fetchCdnBytes(url, label); +} diff --git a/weixin-plugin/weixin/cdn/upload.ts b/weixin-plugin/weixin/cdn/upload.ts new file mode 100644 index 0000000..9e3177a --- /dev/null +++ b/weixin-plugin/weixin/cdn/upload.ts @@ -0,0 +1,155 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { getUploadUrl } from "../api/api.js"; +import type { WeixinApiOptions } from "../api/api.js"; +import { aesEcbPaddedSize } from "./aes-ecb.js"; +import { uploadBufferToCdn } from "./cdn-upload.js"; +import { logger } from "../util/logger.js"; +import { getExtensionFromContentTypeOrUrl } from "../media/mime.js"; +import { tempFileName } from "../util/random.js"; +import { UploadMediaType } from "../api/types.js"; + +export type UploadedFileInfo = { + filekey: string; + /** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */ + downloadEncryptedQueryParam: string; + /** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */ + aeskey: string; + /** Plaintext file size in bytes */ + fileSize: number; + /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */ + fileSizeCiphertext: number; +}; + +/** + * Download a remote media URL (image, video, file) to a local temp file in destDir. + * Returns the local file path; extension is inferred from Content-Type / URL. + */ +export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise { + logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`); + const res = await fetch(url); + if (!res.ok) { + const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`; + logger.error(`downloadRemoteImageToTemp: ${msg}`); + throw new Error(msg); + } + const buf = Buffer.from(await res.arrayBuffer()); + logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`); + await fs.mkdir(destDir, { recursive: true }); + const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url); + const name = tempFileName("weixin-remote", ext); + const filePath = path.join(destDir, name); + await fs.writeFile(filePath, buf); + logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`); + return filePath; +} + +/** + * Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info. + */ +async function uploadMediaToCdn(params: { + filePath: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; + mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType]; + label: string; +}): Promise { + const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params; + + const plaintext = await fs.readFile(filePath); + const rawsize = plaintext.length; + const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex"); + const filesize = aesEcbPaddedSize(rawsize); + const filekey = crypto.randomBytes(16).toString("hex"); + const aeskey = crypto.randomBytes(16); + + logger.debug( + `${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`, + ); + + const uploadUrlResp = await getUploadUrl({ + ...opts, + filekey, + media_type: mediaType, + to_user_id: toUserId, + rawsize, + rawfilemd5, + filesize, + no_need_thumb: true, + aeskey: aeskey.toString("hex"), + }); + + const uploadParam = uploadUrlResp.upload_param; + if (!uploadParam) { + logger.error( + `${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`, + ); + throw new Error(`${label}: getUploadUrl returned no upload_param`); + } + + const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({ + buf: plaintext, + uploadParam, + filekey, + cdnBaseUrl, + aeskey, + label: `${label}[orig filekey=${filekey}]`, + }); + + return { + filekey, + downloadEncryptedQueryParam, + aeskey: aeskey.toString("hex"), + fileSize: rawsize, + fileSizeCiphertext: filesize, + }; +} + +/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */ +export async function uploadFileToWeixin(params: { + filePath: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; +}): Promise { + return uploadMediaToCdn({ + ...params, + mediaType: UploadMediaType.IMAGE, + label: "uploadFileToWeixin", + }); +} + +/** Upload a local video file to the Weixin CDN. */ +export async function uploadVideoToWeixin(params: { + filePath: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; +}): Promise { + return uploadMediaToCdn({ + ...params, + mediaType: UploadMediaType.VIDEO, + label: "uploadVideoToWeixin", + }); +} + +/** + * Upload a local file attachment (non-image, non-video) to the Weixin CDN. + * Uses media_type=FILE; no thumbnail required. + */ +export async function uploadFileAttachmentToWeixin(params: { + filePath: string; + fileName: string; + toUserId: string; + opts: WeixinApiOptions; + cdnBaseUrl: string; +}): Promise { + return uploadMediaToCdn({ + ...params, + mediaType: UploadMediaType.FILE, + label: "uploadFileAttachmentToWeixin", + }); +} diff --git a/weixin-plugin/weixin/channel.ts b/weixin-plugin/weixin/channel.ts new file mode 100644 index 0000000..3a6f9c4 --- /dev/null +++ b/weixin-plugin/weixin/channel.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "./runtime.js"; + +export { startWeixinLoginWithQr, waitForWeixinLogin } from "./auth/login-qr.js"; +export { DEFAULT_ILINK_BOT_TYPE } from "./auth/login-qr.js"; +export { + loadWeixinAccount, + saveWeixinAccount, + listWeixinAccountIds, + resolveWeixinAccount, + registerWeixinAccountId, + triggerWeixinChannelReload, + clearStaleAccountsForUserId, + DEFAULT_BASE_URL, + CDN_BASE_URL, +} from "./auth/accounts.js"; +export { assertSessionActive } from "./api/session-guard.js"; +export { + getContextToken, + findAccountIdsByContextToken, + restoreContextTokens, + clearContextTokensForAccount, +} from "./messaging/inbound.js"; +export { logger } from "./util/logger.js"; +export { monitorWeixinProvider } from "./monitor/monitor.js"; +export { sendWeixinMediaFile } from "./messaging/send-media.js"; +export { sendMessageWeixin } from "./messaging/send.js"; +export { downloadRemoteImageToTemp } from "./cdn/upload.js"; + +const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp"); + +export { MEDIA_OUTBOUND_TEMP_DIR }; + +export function deriveRawAccountId(raw: string): string { + return raw.replace(/@im\.bot$/, "").replace(/[^a-zA-Z0-9_\-]/g, "_"); +} + +export function resolveOutboundAccountId( + _cfg: any, + to: string, +): string { + const allIds = listWeixinAccountIds(_cfg); + + if (allIds.length === 0) { + throw new Error("weixin: no accounts registered"); + } + + if (allIds.length === 1) { + return allIds[0]; + } + + const matched = findAccountIdsByContextToken(allIds, to); + + if (matched.length === 1) { + return matched[0]; + } + + if (matched.length > 1) { + throw new Error( + `weixin: ambiguous account for to=${to} (${matched.length} accounts matched: ${matched.join(", ")})` + ); + } + + throw new Error( + `weixin: cannot determine which account to use for to=${to}` + ); +} + +export function isLocalFilePath(mediaUrl: string): boolean { + return !mediaUrl.includes("://"); +} + +export function isRemoteUrl(mediaUrl: string): boolean { + return mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://"); +} + +export function resolveLocalPath(mediaUrl: string): string { + if (mediaUrl.startsWith("file://")) return new URL(mediaUrl).pathname; + if (!path.isAbsolute(mediaUrl)) return path.resolve(mediaUrl); + return mediaUrl; +} \ No newline at end of file diff --git a/weixin-plugin/weixin/config/config-schema.ts b/weixin-plugin/weixin/config/config-schema.ts new file mode 100644 index 0000000..e23c67c --- /dev/null +++ b/weixin-plugin/weixin/config/config-schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { CDN_BASE_URL, DEFAULT_BASE_URL } from "../auth/accounts.js"; + +// --------------------------------------------------------------------------- +// Zod config schema +// --------------------------------------------------------------------------- + +const weixinAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + baseUrl: z.string().default(DEFAULT_BASE_URL), + cdnBaseUrl: z.string().default(CDN_BASE_URL), + routeTag: z.number().optional(), +}); + +/** Top-level weixin config schema (token is stored in credentials file, not config). */ +export const WeixinConfigSchema = weixinAccountSchema.extend({ + accounts: z.record(z.string(), weixinAccountSchema).optional(), + /** Default URL for `openclaw openclaw-weixin logs-upload`. Set via `openclaw config set channels.openclaw-weixin.logUploadUrl `. */ + logUploadUrl: z.string().optional(), +}); diff --git a/weixin-plugin/weixin/log-upload.ts b/weixin-plugin/weixin/log-upload.ts new file mode 100644 index 0000000..13c15a9 --- /dev/null +++ b/weixin-plugin/weixin/log-upload.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +function resolvePreferredOpenClawTmpDir(): string { + const envOverride = process.env.OPENCLAW_TMP_DIR?.trim(); + if (envOverride) return envOverride; + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw"); + return path.join(stateDir, "tmp"); +} + +function mainLogDir(): string { + return resolvePreferredOpenClawTmpDir(); +} + +function currentDayLogFileName(): string { + const now = new Date(); + const offsetMs = -now.getTimezoneOffset() * 60_000; + const dateKey = new Date(now.getTime() + offsetMs).toISOString().slice(0, 10); + return `originclawai-${dateKey}.log`; +} + +function resolveLogFileName(file: string): string { + if (/^\d{8}$/.test(file)) { + const yyyy = file.slice(0, 4); + const mm = file.slice(4, 6); + const dd = file.slice(6, 8); + return `originclawai-${yyyy}-${mm}-${dd}.log`; + } + if (/^\d{10}$/.test(file)) { + const yyyy = file.slice(0, 4); + const mm = file.slice(4, 6); + const dd = file.slice(6, 8); + return `originclawai-${yyyy}-${mm}-${dd}.log`; + } + return file; +} + +export async function uploadWeixinLog(params: { + uploadUrl: string; + file?: string; +}): Promise { + const { uploadUrl, file } = params; + + const logDir = mainLogDir(); + const rawFile = file ?? currentDayLogFileName(); + const fileName = resolveLogFileName(rawFile); + const filePath = path.isAbsolute(fileName) ? fileName : path.join(logDir, fileName); + + let content: Buffer; + try { + content = await fs.readFile(filePath); + } catch (err) { + throw new Error(`Failed to read log file: ${filePath}: ${String(err)}`); + } + + const formData = new FormData(); + formData.append("file", new Blob([new Uint8Array(content)], { type: "text/plain" }), fileName); + + const res = await fetch(uploadUrl, { method: "POST", body: formData }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Upload failed: HTTP ${res.status} ${res.statusText}: ${body}`); + } +} \ No newline at end of file diff --git a/weixin-plugin/weixin/media/media-download.ts b/weixin-plugin/weixin/media/media-download.ts new file mode 100644 index 0000000..38eeb2f --- /dev/null +++ b/weixin-plugin/weixin/media/media-download.ts @@ -0,0 +1,141 @@ +import type { WeixinInboundMediaOpts } from "../messaging/inbound.js"; +import { logger } from "../util/logger.js"; +import { getMimeFromFilename } from "./mime.js"; +import { + downloadAndDecryptBuffer, + downloadPlainCdnBuffer, +} from "../cdn/pic-decrypt.js"; +import { silkToWav } from "./silk-transcode.js"; +import type { WeixinMessage } from "../api/types.js"; +import { MessageItemType } from "../api/types.js"; + +const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024; + +/** Persist a buffer via the framework's unified media store. */ +type SaveMediaFn = ( + buffer: Buffer, + contentType?: string, + subdir?: string, + maxBytes?: number, + originalFilename?: string, +) => Promise<{ path: string }>; + +/** + * Download and decrypt media from a single MessageItem. + * Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure. + */ +export async function downloadMediaFromItem( + item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never, + deps: { + cdnBaseUrl: string; + saveMedia: SaveMediaFn; + log: (msg: string) => void; + errLog: (msg: string) => void; + label: string; + }, +): Promise { + const { cdnBaseUrl, saveMedia, log, errLog, label } = deps; + const result: WeixinInboundMediaOpts = {}; + + if (item.type === MessageItemType.IMAGE) { + const img = item.image_item; + if (!img?.media?.encrypt_query_param) return result; + const aesKeyBase64 = img.aeskey + ? Buffer.from(img.aeskey, "hex").toString("base64") + : img.media.aes_key; + logger.debug( + `${label} image: encrypt_query_param=${img.media.encrypt_query_param.slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"}`, + ); + try { + const buf = aesKeyBase64 + ? await downloadAndDecryptBuffer( + img.media.encrypt_query_param, + aesKeyBase64, + cdnBaseUrl, + `${label} image`, + ) + : await downloadPlainCdnBuffer( + img.media.encrypt_query_param, + cdnBaseUrl, + `${label} image-plain`, + ); + const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedPicPath = saved.path; + logger.debug(`${label} image saved: ${saved.path}`); + } catch (err) { + logger.error(`${label} image download/decrypt failed: ${String(err)}`); + errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`); + } + } else if (item.type === MessageItemType.VOICE) { + const voice = item.voice_item; + if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result; + try { + const silkBuf = await downloadAndDecryptBuffer( + voice.media.encrypt_query_param, + voice.media.aes_key, + cdnBaseUrl, + `${label} voice`, + ); + logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`); + const wavBuf = await silkToWav(silkBuf); + if (wavBuf) { + const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedVoicePath = saved.path; + result.voiceMediaType = "audio/wav"; + logger.debug(`${label} voice: saved WAV to ${saved.path}`); + } else { + const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedVoicePath = saved.path; + result.voiceMediaType = "audio/silk"; + logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`); + } + } catch (err) { + logger.error(`${label} voice download/transcode failed: ${String(err)}`); + errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`); + } + } else if (item.type === MessageItemType.FILE) { + const fileItem = item.file_item; + if (!fileItem?.media?.encrypt_query_param || !fileItem.media.aes_key) return result; + try { + const buf = await downloadAndDecryptBuffer( + fileItem.media.encrypt_query_param, + fileItem.media.aes_key, + cdnBaseUrl, + `${label} file`, + ); + const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin"); + const saved = await saveMedia( + buf, + mime, + "inbound", + WEIXIN_MEDIA_MAX_BYTES, + fileItem.file_name ?? undefined, + ); + result.decryptedFilePath = saved.path; + result.fileMediaType = mime; + logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`); + } catch (err) { + logger.error(`${label} file download failed: ${String(err)}`); + errLog(`weixin ${label} file download failed: ${String(err)}`); + } + } else if (item.type === MessageItemType.VIDEO) { + const videoItem = item.video_item; + if (!videoItem?.media?.encrypt_query_param || !videoItem.media.aes_key) return result; + try { + const buf = await downloadAndDecryptBuffer( + videoItem.media.encrypt_query_param, + videoItem.media.aes_key, + cdnBaseUrl, + `${label} video`, + ); + const saved = await saveMedia(buf, "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES); + result.decryptedVideoPath = saved.path; + logger.debug(`${label} video: saved to ${saved.path}`); + } catch (err) { + logger.error(`${label} video download failed: ${String(err)}`); + errLog(`weixin ${label} video download failed: ${String(err)}`); + } + } + + return result; +} diff --git a/weixin-plugin/weixin/media/mime.ts b/weixin-plugin/weixin/media/mime.ts new file mode 100644 index 0000000..08f39e2 --- /dev/null +++ b/weixin-plugin/weixin/media/mime.ts @@ -0,0 +1,76 @@ +import path from "node:path"; + +const EXTENSION_TO_MIME: Record = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".txt": "text/plain", + ".csv": "text/csv", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +}; + +const MIME_TO_EXTENSION: Record = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "video/mp4": ".mp4", + "video/quicktime": ".mov", + "video/webm": ".webm", + "video/x-matroska": ".mkv", + "video/x-msvideo": ".avi", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "application/pdf": ".pdf", + "application/zip": ".zip", + "application/x-tar": ".tar", + "application/gzip": ".gz", + "text/plain": ".txt", + "text/csv": ".csv", +}; + +/** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */ +export function getMimeFromFilename(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + return EXTENSION_TO_MIME[ext] ?? "application/octet-stream"; +} + +/** Get file extension from MIME type. Returns ".bin" for unknown types. */ +export function getExtensionFromMime(mimeType: string): string { + const ct = mimeType.split(";")[0].trim().toLowerCase(); + return MIME_TO_EXTENSION[ct] ?? ".bin"; +} + +/** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */ +export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string { + if (contentType) { + const ext = getExtensionFromMime(contentType); + if (ext !== ".bin") return ext; + } + const ext = path.extname(new URL(url).pathname).toLowerCase(); + const knownExts = new Set(Object.keys(EXTENSION_TO_MIME)); + return knownExts.has(ext) ? ext : ".bin"; +} diff --git a/weixin-plugin/weixin/media/silk-transcode.ts b/weixin-plugin/weixin/media/silk-transcode.ts new file mode 100644 index 0000000..473458a --- /dev/null +++ b/weixin-plugin/weixin/media/silk-transcode.ts @@ -0,0 +1,74 @@ +import { logger } from "../util/logger.js"; + +/** Default sample rate for Weixin voice messages. */ +const SILK_SAMPLE_RATE = 24_000; + +/** + * Wrap raw pcm_s16le bytes in a WAV container. + * Mono channel, 16-bit signed little-endian. + */ +function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer { + const pcmBytes = pcm.byteLength; + const totalSize = 44 + pcmBytes; + const buf = Buffer.allocUnsafe(totalSize); + let offset = 0; + + buf.write("RIFF", offset); + offset += 4; + buf.writeUInt32LE(totalSize - 8, offset); + offset += 4; + buf.write("WAVE", offset); + offset += 4; + + buf.write("fmt ", offset); + offset += 4; + buf.writeUInt32LE(16, offset); + offset += 4; // fmt chunk size + buf.writeUInt16LE(1, offset); + offset += 2; // PCM format + buf.writeUInt16LE(1, offset); + offset += 2; // mono + buf.writeUInt32LE(sampleRate, offset); + offset += 4; + buf.writeUInt32LE(sampleRate * 2, offset); + offset += 4; // byte rate (mono 16-bit) + buf.writeUInt16LE(2, offset); + offset += 2; // block align + buf.writeUInt16LE(16, offset); + offset += 2; // bits per sample + + buf.write("data", offset); + offset += 4; + buf.writeUInt32LE(pcmBytes, offset); + offset += 4; + + Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset); + + return buf; +} + +/** + * Try to transcode a SILK audio buffer to WAV using silk-wasm. + * silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }. + * + * Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails. + * Callers should fall back to passing the raw SILK file when null is returned. + */ +export async function silkToWav(silkBuf: Buffer): Promise { + try { + const { decode } = await import("silk-wasm"); + + logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`); + const result = await decode(silkBuf, SILK_SAMPLE_RATE); + logger.debug( + `silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`, + ); + + const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE); + logger.debug(`silkToWav: WAV size=${wav.length}`); + return wav; + } catch (err) { + logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`); + return null; + } +} diff --git a/weixin-plugin/weixin/messaging/debug-mode.ts b/weixin-plugin/weixin/messaging/debug-mode.ts new file mode 100644 index 0000000..7d4ce09 --- /dev/null +++ b/weixin-plugin/weixin/messaging/debug-mode.ts @@ -0,0 +1,69 @@ +/** + * Per-bot debug mode toggle, persisted to disk so it survives gateway restarts. + * + * State file: `/openclaw-weixin/debug-mode.json` + * Format: `{ "accounts": { "": true, ... } }` + * + * When enabled, processOneMessage appends a timing summary after each + * AI reply is delivered to the user. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "../storage/state-dir.js"; +import { logger } from "../util/logger.js"; + +interface DebugModeState { + accounts: Record; +} + +function resolveDebugModePath(): string { + return path.join(resolveStateDir(), "openclaw-weixin", "debug-mode.json"); +} + +function loadState(): DebugModeState { + try { + const raw = fs.readFileSync(resolveDebugModePath(), "utf-8"); + const parsed = JSON.parse(raw) as DebugModeState; + if (parsed && typeof parsed.accounts === "object") return parsed; + } catch { + // missing or corrupt — start fresh + } + return { accounts: {} }; +} + +function saveState(state: DebugModeState): void { + const filePath = resolveDebugModePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8"); +} + +/** Toggle debug mode for a bot account. Returns the new state. */ +export function toggleDebugMode(accountId: string): boolean { + const state = loadState(); + const next = !state.accounts[accountId]; + state.accounts[accountId] = next; + try { + saveState(state); + } catch (err) { + logger.error(`debug-mode: failed to persist state: ${String(err)}`); + } + return next; +} + +/** Check whether debug mode is active for a bot account. */ +export function isDebugMode(accountId: string): boolean { + return loadState().accounts[accountId] === true; +} + +/** + * Reset internal state — only for tests. + * @internal + */ +export function _resetForTest(): void { + try { + fs.unlinkSync(resolveDebugModePath()); + } catch { + // ignore if not present + } +} diff --git a/weixin-plugin/weixin/messaging/error-notice.ts b/weixin-plugin/weixin/messaging/error-notice.ts new file mode 100644 index 0000000..0845bf9 --- /dev/null +++ b/weixin-plugin/weixin/messaging/error-notice.ts @@ -0,0 +1,30 @@ +import { logger } from "../util/logger.js"; +import { sendMessageWeixin } from "./send.js"; + +/** + * Send a plain-text error notice back to the user. + * Fire-and-forget: errors are logged but never thrown, so callers stay unaffected. + * No-op when contextToken is absent (we have no conversation reference to reply into). + */ +export async function sendWeixinErrorNotice(params: { + to: string; + contextToken: string | undefined; + message: string; + baseUrl: string; + token?: string; + errLog: (m: string) => void; +}): Promise { + if (!params.contextToken) { + logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, sending without context`); + } + try { + await sendMessageWeixin({ to: params.to, text: params.message, opts: { + baseUrl: params.baseUrl, + token: params.token, + contextToken: params.contextToken, + }}); + logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`); + } catch (err) { + params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`); + } +} diff --git a/weixin-plugin/weixin/messaging/inbound.ts b/weixin-plugin/weixin/messaging/inbound.ts new file mode 100644 index 0000000..fc25ab8 --- /dev/null +++ b/weixin-plugin/weixin/messaging/inbound.ts @@ -0,0 +1,262 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { logger } from "../util/logger.js"; +import { generateId } from "../util/random.js"; +import type { WeixinMessage, MessageItem } from "../api/types.js"; +import { MessageItemType } from "../api/types.js"; +import { resolveStateDir } from "../storage/state-dir.js"; + +// --------------------------------------------------------------------------- +// Context token store (in-process cache + disk persistence) +// --------------------------------------------------------------------------- + +/** + * contextToken is issued per-message by the Weixin getupdates API and must + * be echoed verbatim in every outbound send. The in-memory map is the primary + * lookup; a disk-backed file per account ensures tokens survive gateway restarts. + */ +const contextTokenStore = new Map(); + +function contextTokenKey(accountId: string, userId: string): string { + return `${accountId}:${userId}`; +} + +// --------------------------------------------------------------------------- +// Disk persistence helpers +// --------------------------------------------------------------------------- + +function resolveContextTokenFilePath(accountId: string): string { + return path.join( + resolveStateDir(), + "openclaw-weixin", + "accounts", + `${accountId}.context-tokens.json`, + ); +} + +/** Persist all context tokens for a given account to disk. */ +function persistContextTokens(accountId: string): void { + const prefix = `${accountId}:`; + const tokens: Record = {}; + for (const [k, v] of contextTokenStore) { + if (k.startsWith(prefix)) { + tokens[k.slice(prefix.length)] = v; + } + } + const filePath = resolveContextTokenFilePath(accountId); + try { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8"); + } catch (err) { + logger.warn(`persistContextTokens: failed to write ${filePath}: ${String(err)}`); + } +} + +/** + * Restore persisted context tokens for an account into the in-memory map. + * Called once during gateway startAccount to survive restarts. + */ +export function restoreContextTokens(accountId: string): void { + const filePath = resolveContextTokenFilePath(accountId); + try { + if (!fs.existsSync(filePath)) return; + const raw = fs.readFileSync(filePath, "utf-8"); + const tokens = JSON.parse(raw) as Record; + let count = 0; + for (const [userId, token] of Object.entries(tokens)) { + if (typeof token === "string" && token) { + contextTokenStore.set(contextTokenKey(accountId, userId), token); + count++; + } + } + logger.info(`restoreContextTokens: restored ${count} tokens for account=${accountId}`); + } catch (err) { + logger.warn(`restoreContextTokens: failed to read ${filePath}: ${String(err)}`); + } +} + +/** Remove all context tokens for a given account (memory + disk). */ +export function clearContextTokensForAccount(accountId: string): void { + const prefix = `${accountId}:`; + for (const k of [...contextTokenStore.keys()]) { + if (k.startsWith(prefix)) { + contextTokenStore.delete(k); + } + } + const filePath = resolveContextTokenFilePath(accountId); + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch (err) { + logger.warn(`clearContextTokensForAccount: failed to remove ${filePath}: ${String(err)}`); + } + logger.info(`clearContextTokensForAccount: cleared tokens for account=${accountId}`); +} + +/** Store a context token for a given account+user pair (memory + disk). */ +export function setContextToken(accountId: string, userId: string, token: string): void { + const k = contextTokenKey(accountId, userId); + logger.debug(`setContextToken: key=${k}`); + contextTokenStore.set(k, token); + persistContextTokens(accountId); +} + +/** Retrieve the cached context token for a given account+user pair. */ +export function getContextToken(accountId: string, userId: string): string | undefined { + const k = contextTokenKey(accountId, userId); + const val = contextTokenStore.get(k); + logger.debug( + `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`, + ); + return val; +} + +/** + * Find all accountIds that have an active contextToken for the given userId. + * Used to infer the sending bot account from the recipient address when + * accountId is not explicitly provided (e.g. cron delivery). + * + * Returns all matching accountIds (not just the first) so the caller can + * detect ambiguity when multiple accounts have sessions with the same user. + */ +export function findAccountIdsByContextToken( + accountIds: string[], + userId: string, +): string[] { + return accountIds.filter((id) => contextTokenStore.has(contextTokenKey(id, userId))); +} + +// --------------------------------------------------------------------------- +// Message ID generation +// --------------------------------------------------------------------------- + +function generateMessageSid(): string { + return generateId("openclaw-weixin"); +} + +/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */ +export type WeixinMsgContext = { + Body: string; + From: string; + To: string; + AccountId: string; + OriginatingChannel: "openclaw-weixin"; + OriginatingTo: string; + MessageSid: string; + Timestamp?: number; + Provider: "openclaw-weixin"; + ChatType: "direct"; + /** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */ + SessionKey?: string; + context_token?: string; + MediaUrl?: string; + MediaPath?: string; + MediaType?: string; + /** Raw message body for framework command authorization. */ + CommandBody?: string; + /** Whether the sender is authorized to execute slash commands. */ + CommandAuthorized?: boolean; +}; + +/** Returns true if the message item is a media type (image, video, file, or voice). */ +export function isMediaItem(item: MessageItem): boolean { + return ( + item.type === MessageItemType.IMAGE || + item.type === MessageItemType.VIDEO || + item.type === MessageItemType.FILE || + item.type === MessageItemType.VOICE + ); +} + +function bodyFromItemList(itemList?: MessageItem[]): string { + if (!itemList?.length) return ""; + for (const item of itemList) { + if (item.type === MessageItemType.TEXT && item.text_item?.text != null) { + const text = String(item.text_item.text); + const ref = item.ref_msg; + if (!ref) return text; + // Quoted media is passed as MediaPath; only include the current text as body. + if (ref.message_item && isMediaItem(ref.message_item)) return text; + // Build quoted context from both title and message_item content. + const parts: string[] = []; + if (ref.title) parts.push(ref.title); + if (ref.message_item) { + const refBody = bodyFromItemList([ref.message_item]); + if (refBody) parts.push(refBody); + } + if (!parts.length) return text; + return `[引用: ${parts.join(" | ")}]\n${text}`; + } + // 语音转文字:如果语音消息有 text 字段,直接使用文字内容 + if (item.type === MessageItemType.VOICE && item.voice_item?.text) { + return item.voice_item.text; + } + } + return ""; +} + +export type WeixinInboundMediaOpts = { + /** Local path to decrypted image file. */ + decryptedPicPath?: string; + /** Local path to transcoded/raw voice file (.wav or .silk). */ + decryptedVoicePath?: string; + /** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */ + voiceMediaType?: string; + /** Local path to decrypted file attachment. */ + decryptedFilePath?: string; + /** MIME type for the file attachment (guessed from file_name). */ + fileMediaType?: string; + /** Local path to decrypted video file. */ + decryptedVideoPath?: string; +}; + +/** + * Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline. + * Media: only pass MediaPath (local file, after CDN download + decrypt). + * We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only. + * Priority when multiple media types present: image > video > file > voice. + */ +export function weixinMessageToMsgContext( + msg: WeixinMessage, + accountId: string, + opts?: WeixinInboundMediaOpts, +): WeixinMsgContext { + const from_user_id = msg.from_user_id ?? ""; + const ctx: WeixinMsgContext = { + Body: bodyFromItemList(msg.item_list), + From: from_user_id, + To: from_user_id, + AccountId: accountId, + OriginatingChannel: "openclaw-weixin", + OriginatingTo: from_user_id, + MessageSid: generateMessageSid(), + Timestamp: msg.create_time_ms, + Provider: "openclaw-weixin", + ChatType: "direct", + }; + if (msg.context_token) { + ctx.context_token = msg.context_token; + } + + if (opts?.decryptedPicPath) { + ctx.MediaPath = opts.decryptedPicPath; + ctx.MediaType = "image/*"; + } else if (opts?.decryptedVideoPath) { + ctx.MediaPath = opts.decryptedVideoPath; + ctx.MediaType = "video/mp4"; + } else if (opts?.decryptedFilePath) { + ctx.MediaPath = opts.decryptedFilePath; + ctx.MediaType = opts.fileMediaType ?? "application/octet-stream"; + } else if (opts?.decryptedVoicePath) { + ctx.MediaPath = opts.decryptedVoicePath; + ctx.MediaType = opts.voiceMediaType ?? "audio/wav"; + } + + return ctx; +} + +/** Extract the context_token from an inbound WeixinMsgContext. */ +export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined { + return ctx.context_token; +} diff --git a/weixin-plugin/weixin/messaging/process-message.ts b/weixin-plugin/weixin/messaging/process-message.ts new file mode 100644 index 0000000..933bf30 --- /dev/null +++ b/weixin-plugin/weixin/messaging/process-message.ts @@ -0,0 +1,33 @@ +import type { WeixinMessage } from "../api/types.js"; +import { MessageItemType } from "../api/types.js"; +import type { WeixinInboundMediaOpts } from "./inbound.js"; + +export type ProcessMessageDeps = { + accountId: string; + baseUrl: string; + cdnBaseUrl: string; + token?: string; + typingTicket?: string; + log: (msg: string) => void; + errLog: (m: string) => void; +}; + +function extractTextBody(itemList?: import("../api/types.js").MessageItem[]): string { + if (!itemList?.length) return ""; + for (const item of itemList) { + if (item.type === MessageItemType.TEXT && item.text_item?.text != null) { + return String(item.text_item.text); + } + } + return ""; +} + +export async function processOneMessage( + full: WeixinMessage, + deps: ProcessMessageDeps, +): Promise { + const textBody = extractTextBody(full.item_list); + const fromUserId = full.from_user_id ?? ""; + + deps.log(`[weixin] inbound message from=${fromUserId} bodyLen=${textBody.length}`); +} \ No newline at end of file diff --git a/weixin-plugin/weixin/messaging/send-media.ts b/weixin-plugin/weixin/messaging/send-media.ts new file mode 100644 index 0000000..df56a27 --- /dev/null +++ b/weixin-plugin/weixin/messaging/send-media.ts @@ -0,0 +1,72 @@ +import path from "node:path"; +import type { WeixinApiOptions } from "../api/api.js"; +import { logger } from "../util/logger.js"; +import { getMimeFromFilename } from "../media/mime.js"; +import { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from "./send.js"; +import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from "../cdn/upload.js"; + +/** + * Upload a local file and send it as a weixin message, routing by MIME type: + * video/* → uploadVideoToWeixin + sendVideoMessageWeixin + * image/* → uploadFileToWeixin + sendImageMessageWeixin + * else → uploadFileAttachmentToWeixin + sendFileMessageWeixin + * + * Used by both the auto-reply deliver path (monitor.ts) and the outbound + * sendMedia path (channel.ts) so they stay in sync. + */ +export async function sendWeixinMediaFile(params: { + filePath: string; + to: string; + text: string; + opts: WeixinApiOptions & { contextToken?: string }; + cdnBaseUrl: string; +}): Promise<{ messageId: string }> { + const { filePath, to, text, opts, cdnBaseUrl } = params; + const mime = getMimeFromFilename(filePath); + const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token }; + + if (mime.startsWith("video/")) { + logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`); + const uploaded = await uploadVideoToWeixin({ + filePath, + toUserId: to, + opts: uploadOpts, + cdnBaseUrl, + }); + logger.info( + `[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`, + ); + return sendVideoMessageWeixin({ to, text, uploaded, opts }); + } + + if (mime.startsWith("image/")) { + logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`); + const uploaded = await uploadFileToWeixin({ + filePath, + toUserId: to, + opts: uploadOpts, + cdnBaseUrl, + }); + logger.info( + `[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`, + ); + return sendImageMessageWeixin({ to, text, uploaded, opts }); + } + + // File attachment: pdf, doc, zip, etc. + const fileName = path.basename(filePath); + logger.info( + `[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`, + ); + const uploaded = await uploadFileAttachmentToWeixin({ + filePath, + fileName, + toUserId: to, + opts: uploadOpts, + cdnBaseUrl, + }); + logger.info( + `[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`, + ); + return sendFileMessageWeixin({ to, text, fileName, uploaded, opts }); +} diff --git a/weixin-plugin/weixin/messaging/send.ts b/weixin-plugin/weixin/messaging/send.ts new file mode 100644 index 0000000..77b3d6e --- /dev/null +++ b/weixin-plugin/weixin/messaging/send.ts @@ -0,0 +1,209 @@ +import { stripMarkdown } from "./strip-markdown.js"; + +import { sendMessage as sendMessageApi } from "../api/api.js"; +import type { WeixinApiOptions } from "../api/api.js"; +import { logger } from "../util/logger.js"; +import { generateId } from "../util/random.js"; +import type { MessageItem, SendMessageReq } from "../api/types.js"; +import { MessageItemType, MessageState, MessageType } from "../api/types.js"; +import type { UploadedFileInfo } from "../cdn/upload.js"; + +function generateClientId(): string { + return generateId("openclaw-weixin"); +} + +export function markdownToPlainText(text: string): string { + let result = text; + result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_: string, code: string) => code.trim()); + result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); + result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); + result = result.replace(/^\|[\s:|-]+\|$/gm, ""); + result = result.replace(/^\|(.+)\|$/gm, (_: string, inner: string) => + inner.split("|").map((cell) => cell.trim()).join(" "), + ); + result = stripMarkdown(result); + return result; +} + +function buildTextMessageReq(params: { + to: string; + text: string; + contextToken?: string; + clientId: string; +}): SendMessageReq { + const { to, text, contextToken, clientId } = params; + const item_list: MessageItem[] = text + ? [{ type: MessageItemType.TEXT, text_item: { text } }] + : []; + return { + msg: { + from_user_id: "", + to_user_id: to, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + item_list: item_list.length ? item_list : undefined, + context_token: contextToken ?? undefined, + }, + }; +} + +export async function sendMessageWeixin(params: { + to: string; + text: string; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, opts } = params; + if (!opts.contextToken) { + logger.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`); + } + const clientId = generateClientId(); + const req = buildTextMessageReq({ + to, + text, + contextToken: opts.contextToken, + clientId, + }); + try { + await sendMessageApi({ + baseUrl: opts.baseUrl, + token: opts.token, + timeoutMs: opts.timeoutMs, + body: req, + }); + } catch (err) { + logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`); + throw err; + } + return { messageId: clientId }; +} + +async function sendMediaItems(params: { + to: string; + text: string; + mediaItem: MessageItem; + opts: WeixinApiOptions & { contextToken?: string }; + label: string; +}): Promise<{ messageId: string }> { + const { to, text, mediaItem, opts, label } = params; + + const items: MessageItem[] = []; + if (text) { + items.push({ type: MessageItemType.TEXT, text_item: { text } }); + } + items.push(mediaItem); + + let lastClientId = ""; + for (const item of items) { + lastClientId = generateClientId(); + const req: SendMessageReq = { + msg: { + from_user_id: "", + to_user_id: to, + client_id: lastClientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + item_list: [item], + context_token: opts.contextToken ?? undefined, + }, + }; + try { + await sendMessageApi({ + baseUrl: opts.baseUrl, + token: opts.token, + timeoutMs: opts.timeoutMs, + body: req, + }); + } catch (err) { + logger.error( + `${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`, + ); + throw err; + } + } + + logger.info(`${label}: success to=${to} clientId=${lastClientId}`); + return { messageId: lastClientId }; +} + +export async function sendImageMessageWeixin(params: { + to: string; + text: string; + uploaded: UploadedFileInfo; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, uploaded, opts } = params; + if (!opts.contextToken) { + logger.warn(`sendImageMessageWeixin: contextToken missing for to=${to}, sending without context`); + } + logger.info( + `sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`, + ); + + const imageItem: MessageItem = { + type: MessageItemType.IMAGE, + image_item: { + media: { + encrypt_query_param: uploaded.downloadEncryptedQueryParam, + aes_key: Buffer.from(uploaded.aeskey).toString("base64"), + encrypt_type: 1, + }, + mid_size: uploaded.fileSizeCiphertext, + }, + }; + + return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" }); +} + +export async function sendVideoMessageWeixin(params: { + to: string; + text: string; + uploaded: UploadedFileInfo; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, uploaded, opts } = params; + if (!opts.contextToken) { + logger.warn(`sendVideoMessageWeixin: contextToken missing for to=${to}, sending without context`); + } + + const videoItem: MessageItem = { + type: MessageItemType.VIDEO, + video_item: { + media: { + encrypt_query_param: uploaded.downloadEncryptedQueryParam, + aes_key: Buffer.from(uploaded.aeskey).toString("base64"), + encrypt_type: 1, + }, + video_size: uploaded.fileSizeCiphertext, + }, + }; + + return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" }); +} + +export async function sendFileMessageWeixin(params: { + to: string; + text: string; + fileName: string; + uploaded: UploadedFileInfo; + opts: WeixinApiOptions & { contextToken?: string }; +}): Promise<{ messageId: string }> { + const { to, text, fileName, uploaded, opts } = params; + if (!opts.contextToken) { + logger.warn(`sendFileMessageWeixin: contextToken missing for to=${to}, sending without context`); + } + const fileItem: MessageItem = { + type: MessageItemType.FILE, + file_item: { + media: { + encrypt_query_param: uploaded.downloadEncryptedQueryParam, + aes_key: Buffer.from(uploaded.aeskey).toString("base64"), + encrypt_type: 1, + }, + file_name: fileName, + len: String(uploaded.fileSize), + }, + }; + + return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" }); +} \ No newline at end of file diff --git a/weixin-plugin/weixin/messaging/slash-commands.ts b/weixin-plugin/weixin/messaging/slash-commands.ts new file mode 100644 index 0000000..9d2160e --- /dev/null +++ b/weixin-plugin/weixin/messaging/slash-commands.ts @@ -0,0 +1,110 @@ +/** + * Weixin 斜杠指令处理模块 + * + * 支持的指令: + * - /echo 直接回复消息(不经过 AI),并附带通道耗时统计 + * - /toggle-debug 开关 debug 模式,启用后每条 AI 回复追加全链路耗时 + */ +import type { WeixinApiOptions } from "../api/api.js"; +import { logger } from "../util/logger.js"; + +import { toggleDebugMode, isDebugMode } from "./debug-mode.js"; +import { sendMessageWeixin } from "./send.js"; + +export interface SlashCommandResult { + /** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */ + handled: boolean; +} + +export interface SlashCommandContext { + to: string; + contextToken?: string; + baseUrl: string; + token?: string; + accountId: string; + log: (msg: string) => void; + errLog: (msg: string) => void; +} + +/** 发送回复消息 */ +async function sendReply(ctx: SlashCommandContext, text: string): Promise { + const opts: WeixinApiOptions & { contextToken?: string } = { + baseUrl: ctx.baseUrl, + token: ctx.token, + contextToken: ctx.contextToken, + }; + await sendMessageWeixin({ to: ctx.to, text, opts }); +} + +/** 处理 /echo 指令 */ +async function handleEcho( + ctx: SlashCommandContext, + args: string, + receivedAt: number, + eventTimestamp?: number, +): Promise { + const message = args.trim(); + if (message) { + await sendReply(ctx, message); + } + const eventTs = eventTimestamp ?? 0; + const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A"; + const timing = [ + "⏱ 通道耗时", + `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`, + `├ 平台→插件: ${platformDelay}`, + `└ 插件处理: ${Date.now() - receivedAt}ms`, + ].join("\n"); + await sendReply(ctx, timing); +} + +/** + * 尝试处理斜杠指令 + * + * @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道 + */ +export async function handleSlashCommand( + content: string, + ctx: SlashCommandContext, + receivedAt: number, + eventTimestamp?: number, +): Promise { + const trimmed = content.trim(); + if (!trimmed.startsWith("/")) { + return { handled: false }; + } + + const spaceIdx = trimmed.indexOf(" "); + const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase(); + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1); + + logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`); + + try { + switch (command) { + case "/echo": + await handleEcho(ctx, args, receivedAt, eventTimestamp); + return { handled: true }; + case "/toggle-debug": { + const enabled = toggleDebugMode(ctx.accountId); + await sendReply( + ctx, + enabled + ? "Debug 模式已开启" + : "Debug 模式已关闭", + ); + return { handled: true }; + } + default: + return { handled: false }; + } + } catch (err) { + logger.error(`[weixin] Slash command error: ${String(err)}`); + try { + await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`); + } catch { + // 发送错误消息也失败了,只能记日志 + } + return { handled: true }; + } +} diff --git a/weixin-plugin/weixin/messaging/strip-markdown.ts b/weixin-plugin/weixin/messaging/strip-markdown.ts new file mode 100644 index 0000000..3f4f0b4 --- /dev/null +++ b/weixin-plugin/weixin/messaging/strip-markdown.ts @@ -0,0 +1,27 @@ +export function markdownToPlainText(text: string): string { + let result = text; + result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_: string, code: string) => code.trim()); + result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); + result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); + result = result.replace(/^\|[\s:|-]+\|$/gm, ""); + result = result.replace(/^\|(.+)\|$/gm, (_: string, inner: string) => + inner.split("|").map((cell) => cell.trim()).join(" "), + ); + result = stripMarkdown(result); + return result; +} + +export function stripMarkdown(text: string): string { + let result = text; + result = result.replace(/\*\*([^*]+)\*\*/g, "$1"); + result = result.replace(/\*([^*]+)\*/g, "$1"); + result = result.replace(/__([^_]+)__/g, "$1"); + result = result.replace(/_([^_]+)_/g, "$1"); + result = result.replace(/~~([^~]+)~~/g, "$1"); + result = result.replace(/`([^`]+)`/g, "$1"); + result = result.replace(/^#{1,6}\s+/gm, ""); + result = result.replace(/^>\s+/gm, ""); + result = result.replace(/^[-*+]\s+/gm, ""); + result = result.replace(/^\d+\.\s+/gm, ""); + return result; +} \ No newline at end of file diff --git a/weixin-plugin/weixin/monitor/monitor.ts b/weixin-plugin/weixin/monitor/monitor.ts new file mode 100644 index 0000000..74a7631 --- /dev/null +++ b/weixin-plugin/weixin/monitor/monitor.ts @@ -0,0 +1,158 @@ +import type { WeixinMessage } from "../api/types.js"; +import { SESSION_EXPIRED_ERRCODE, pauseSession, getRemainingPauseMs } from "../api/session-guard.js"; +import { processOneMessage } from "../messaging/process-message.js"; +import { setContextToken } from "../messaging/inbound.js"; +import { resolveSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js"; +import { logger } from "../util/logger.js"; +import type { Logger } from "../util/logger.js"; +import { redactBody } from "../util/redact.js"; +import { getUpdates } from "../api/api.js"; + +const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; +const MAX_CONSECUTIVE_FAILURES = 3; +const BACKOFF_DELAY_MS = 30_000; +const RETRY_DELAY_MS = 2_000; + +export type MonitorWeixinOpts = { + baseUrl: string; + cdnBaseUrl: string; + token?: string; + accountId: string; + abortSignal?: AbortSignal; + longPollTimeoutMs?: number; + onMessage?: (msg: WeixinMessage) => Promise; +}; + +export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise { + const { + baseUrl, + cdnBaseUrl, + token, + accountId, + abortSignal, + longPollTimeoutMs, + onMessage, + } = opts; + const aLog: Logger = logger.withAccount(accountId); + + aLog.info(`Monitor starting: baseUrl=${baseUrl} accountId=${accountId}`); + + const syncFilePath = resolveSyncBufFilePath(accountId); + const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath); + let getUpdatesBuf = previousGetUpdatesBuf ?? ""; + + if (previousGetUpdatesBuf) { + aLog.info(`Resuming from previous sync buf (${getUpdatesBuf.length} bytes)`); + } + + let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS; + let consecutiveFailures = 0; + + while (!abortSignal?.aborted) { + try { + const resp = await getUpdates({ + baseUrl, + token, + get_updates_buf: getUpdatesBuf, + timeoutMs: nextTimeoutMs, + }); + + if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) { + nextTimeoutMs = resp.longpolling_timeout_ms; + } + + const isApiError = + (resp.ret !== undefined && resp.ret !== 0) || + (resp.errcode !== undefined && resp.errcode !== 0); + + if (isApiError) { + const isSessionExpired = + resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE; + + if (isSessionExpired) { + pauseSession(accountId); + const pauseMs = getRemainingPauseMs(accountId); + aLog.error(`Session expired, pausing for ${Math.ceil(pauseMs / 60_000)} min`); + await sleep(pauseMs, abortSignal); + continue; + } + + consecutiveFailures += 1; + aLog.error(`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg}`); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + aLog.error(`Max consecutive failures reached, backing off 30s`); + consecutiveFailures = 0; + await sleep(BACKOFF_DELAY_MS, abortSignal); + } else { + await sleep(RETRY_DELAY_MS, abortSignal); + } + continue; + } + + consecutiveFailures = 0; + + if (resp.get_updates_buf != null && resp.get_updates_buf !== "") { + saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf); + getUpdatesBuf = resp.get_updates_buf; + } + + const list = resp.msgs ?? []; + for (const full of list) { + const fromUserId = full.from_user_id ?? ""; + aLog.info(`Inbound message from=${fromUserId} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`); + + const contextToken = full.context_token; + if (contextToken && fromUserId) { + setContextToken(accountId, fromUserId, contextToken); + } + + if (onMessage) { + try { + await onMessage(full); + } catch (err) { + aLog.error(`onMessage error: ${String(err)}`); + } + } else { + await processOneMessage(full, { + accountId, + baseUrl, + cdnBaseUrl, + token, + log: (msg) => aLog.info(msg), + errLog: (msg) => aLog.error(msg), + }); + } + } + } catch (err) { + if (abortSignal?.aborted) { + aLog.info(`Monitor stopped (aborted)`); + return; + } + consecutiveFailures += 1; + aLog.error(`getUpdates error: ${String(err)}`); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + aLog.error(`Max consecutive failures, backing off 30s`); + consecutiveFailures = 0; + await sleep(30_000, abortSignal); + } else { + await sleep(2000, abortSignal); + } + } + } + + aLog.info(`Monitor ended`); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(t); + reject(new Error("aborted")); + }, + { once: true }, + ); + }); +} \ No newline at end of file diff --git a/weixin-plugin/weixin/runtime.ts b/weixin-plugin/weixin/runtime.ts new file mode 100644 index 0000000..7cbd6f8 --- /dev/null +++ b/weixin-plugin/weixin/runtime.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +export function resolvePreferredOpenClawTmpDir(): string { + const envOverride = process.env.OPENCLAW_TMP_DIR?.trim(); + if (envOverride) return envOverride; + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw"); + return path.join(stateDir, "tmp"); +} + +export function resolveWeixinTmpDir(): string { + return path.join(resolvePreferredOpenClawTmpDir(), "weixin"); +} + +export function ensureMediaOutboundTempDir(): string { + const dir = path.join(resolveWeixinTmpDir(), "media", "outbound-temp"); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} \ No newline at end of file diff --git a/weixin-plugin/weixin/storage/state-dir.ts b/weixin-plugin/weixin/storage/state-dir.ts new file mode 100644 index 0000000..479c075 --- /dev/null +++ b/weixin-plugin/weixin/storage/state-dir.ts @@ -0,0 +1,15 @@ +import os from "node:os"; +import path from "node:path"; + +/** Resolve the OpenClaw state directory (mirrors core logic in src/infra). */ +export function resolveStateDir(): string { + return ( + process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw") + ); +} + +export function resolveWeixinAccountDataDir(): string { + return path.join(resolveStateDir(), "openclaw-weixin"); +} diff --git a/weixin-plugin/weixin/storage/sync-buf.ts b/weixin-plugin/weixin/storage/sync-buf.ts new file mode 100644 index 0000000..70e532b --- /dev/null +++ b/weixin-plugin/weixin/storage/sync-buf.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +export function resolvePreferredOpenClawTmpDir(): string { + const envOverride = process.env.OPENCLAW_TMP_DIR?.trim(); + if (envOverride) return envOverride; + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw"); + return path.join(stateDir, "tmp"); +} + +export function resolveWeixinAccountDataDir(): string { + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw"); + return path.join(stateDir, "openclaw-weixin"); +} + +export function resolveWeixinAccountsDir(): string { + return path.join(resolveWeixinAccountDataDir(), "accounts"); +} + +export function resolveSyncBufFilePath(accountId: string): string { + return path.join(resolveWeixinAccountsDir(), `${accountId}.sync.json`); +} + +export function resolveTmpDir(): string { + return resolvePreferredOpenClawTmpDir(); +} + +export function loadGetUpdatesBuf(filePath: string): string | null { + try { + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, "utf-8"); + } + } catch { /* ignore */ } + return null; +} + +export function saveGetUpdatesBuf(filePath: string, buf: string): void { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, buf, "utf-8"); +} \ No newline at end of file diff --git a/weixin-plugin/weixin/util/logger.ts b/weixin-plugin/weixin/util/logger.ts new file mode 100644 index 0000000..fad4d88 --- /dev/null +++ b/weixin-plugin/weixin/util/logger.ts @@ -0,0 +1,141 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import * as https from "node:https"; +import * as http from "node:http"; + +const RUNTIME = "node"; +const RUNTIME_VERSION = process.versions.node; +const HOSTNAME = os.hostname() || "unknown"; + +function resolveMainLogDir(): string { + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw"); + return path.join(stateDir, "logs"); +} + +const MAIN_LOG_DIR = resolveMainLogDir(); +const SUBSYSTEM = "originclawai/weixin"; + +const LEVEL_IDS: Record = { + TRACE: 1, + DEBUG: 2, + INFO: 3, + WARN: 4, + ERROR: 5, + FATAL: 6, +}; + +const DEFAULT_LOG_LEVEL = "INFO"; + +function resolveMinLevel(): number { + const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase() || + process.env.LOG_LEVEL?.toUpperCase(); + if (env && env in LEVEL_IDS) return LEVEL_IDS[env]; + return LEVEL_IDS[DEFAULT_LOG_LEVEL]; +} + +let minLevelId = resolveMinLevel(); + +export function setLogLevel(level: string): void { + const upper = level.toUpperCase(); + if (!(upper in LEVEL_IDS)) { + throw new Error(`Invalid log level: ${level}. Valid levels: ${Object.keys(LEVEL_IDS).join(", ")}`); + } + minLevelId = LEVEL_IDS[upper]; +} + +function toLocalISO(now: Date): string { + const offsetMs = -now.getTimezoneOffset() * 60_000; + const sign = offsetMs >= 0 ? "+" : "-"; + const abs = Math.abs(now.getTimezoneOffset()); + const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`; + return new Date(now.getTime() + offsetMs).toISOString().replace("Z", offStr); +} + +function localDateKey(now: Date): string { + return toLocalISO(now).slice(0, 10); +} + +function resolveMainLogPath(): string { + const dateKey = localDateKey(new Date()); + return path.join(MAIN_LOG_DIR, `originclawai-${dateKey}.log`); +} + +let logDirEnsured = false; + +export type Logger = { + info(message: string): void; + debug(message: string): void; + warn(message: string): void; + error(message: string): void; + withAccount(accountId: string): Logger; + getLogFilePath(): string; + close(): void; +}; + +function buildLoggerName(accountId?: string): string { + return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM; +} + +function writeLog(level: string, message: string, accountId?: string): void { + const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO; + if (levelId < minLevelId) return; + + const now = new Date(); + const loggerName = buildLoggerName(accountId); + const prefixedMessage = accountId ? `[${accountId}] ${message}` : message; + const entry = JSON.stringify({ + "0": loggerName, + "1": prefixedMessage, + _meta: { + runtime: RUNTIME, + runtimeVersion: RUNTIME_VERSION, + hostname: HOSTNAME, + name: loggerName, + parentNames: ["originclawai"], + date: now.toISOString(), + logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO, + logLevelName: level, + }, + time: toLocalISO(now), + }); + try { + if (!logDirEnsured) { + fs.mkdirSync(MAIN_LOG_DIR, { recursive: true }); + logDirEnsured = true; + } + fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8"); + } catch { + // Best-effort + } +} + +function createLogger(accountId?: string): Logger { + return { + info(message: string): void { + writeLog("INFO", message, accountId); + }, + debug(message: string): void { + writeLog("DEBUG", message, accountId); + }, + warn(message: string): void { + writeLog("WARN", message, accountId); + }, + error(message: string): void { + writeLog("ERROR", message, accountId); + }, + withAccount(id: string): Logger { + return createLogger(id); + }, + getLogFilePath(): string { + return resolveMainLogPath(); + }, + close(): void { + // No-op + }, + }; +} + +export const logger: Logger = createLogger(); \ No newline at end of file diff --git a/weixin-plugin/weixin/util/random.ts b/weixin-plugin/weixin/util/random.ts new file mode 100644 index 0000000..194124d --- /dev/null +++ b/weixin-plugin/weixin/util/random.ts @@ -0,0 +1,17 @@ +import crypto from "node:crypto"; + +/** + * Generate a prefixed unique ID using timestamp + crypto random bytes. + * Format: `{prefix}:{timestamp}-{8-char hex}` + */ +export function generateId(prefix: string): string { + return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; +} + +/** + * Generate a temporary file name with random suffix. + * Format: `{prefix}-{timestamp}-{8-char hex}{ext}` + */ +export function tempFileName(prefix: string, ext: string): string { + return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`; +} diff --git a/weixin-plugin/weixin/util/redact.ts b/weixin-plugin/weixin/util/redact.ts new file mode 100644 index 0000000..48bc260 --- /dev/null +++ b/weixin-plugin/weixin/util/redact.ts @@ -0,0 +1,54 @@ +const DEFAULT_BODY_MAX_LEN = 200; +const DEFAULT_TOKEN_PREFIX_LEN = 6; + +/** + * Truncate a string, appending a length indicator when trimmed. + * Returns `""` for empty/undefined input. + */ +export function truncate(s: string | undefined, max: number): string { + if (!s) return ""; + if (s.length <= max) return s; + return `${s.slice(0, max)}…(len=${s.length})`; +} + +/** + * Redact a token/secret: show only the first few chars + total length. + * Returns `"(none)"` when absent. + */ +export function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN_PREFIX_LEN): string { + if (!token) return "(none)"; + if (token.length <= prefixLen) return `****(len=${token.length})`; + return `${token.slice(0, prefixLen)}…(len=${token.length})`; +} + +/** Field names whose values should be masked in logged JSON bodies. */ +const SENSITIVE_FIELDS = /\b(context_token|bot_token|token|authorization|Authorization)\b/; + +/** + * Truncate a JSON body string to `maxLen` chars for safe logging. + * Redacts known sensitive fields before truncating. + */ +export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string { + if (!body) return "(empty)"; + // Mask values of known sensitive JSON keys: "key":"value" → "key":"" + const redacted = body.replace( + /"(context_token|bot_token|token|authorization|Authorization)"\s*:\s*"[^"]*"/g, + '"$1":""', + ); + if (redacted.length <= maxLen) return redacted; + return `${redacted.slice(0, maxLen)}…(truncated, totalLen=${redacted.length})`; +} + +/** + * Strip query string (which often contains signatures/tokens) from a URL, + * keeping only origin + pathname. + */ +export function redactUrl(rawUrl: string): string { + try { + const u = new URL(rawUrl); + const base = `${u.origin}${u.pathname}`; + return u.search ? `${base}?` : base; + } catch { + return truncate(rawUrl, 80); + } +} diff --git a/weixin-plugin/weixin/vendor.d.ts b/weixin-plugin/weixin/vendor.d.ts new file mode 100644 index 0000000..996acea --- /dev/null +++ b/weixin-plugin/weixin/vendor.d.ts @@ -0,0 +1,25 @@ +declare module "qrcode-terminal" { + const qrcodeTerminal: { + generate( + text: string, + options?: { small?: boolean }, + callback?: (qr: string) => void, + ): void; + }; + export default qrcodeTerminal; +} + +declare module "fluent-ffmpeg" { + interface FfmpegCommand { + setFfmpegPath(path: string): FfmpegCommand; + seekInput(time: number): FfmpegCommand; + frames(n: number): FfmpegCommand; + outputOptions(opts: string[]): FfmpegCommand; + output(path: string): FfmpegCommand; + on(event: "end", cb: () => void): FfmpegCommand; + on(event: "error", cb: (err: Error) => void): FfmpegCommand; + run(): void; + } + function ffmpeg(input: string): FfmpegCommand; + export default ffmpeg; +} diff --git a/weixin-plugin/weixinGateway.ts b/weixin-plugin/weixinGateway.ts new file mode 100644 index 0000000..cb2bcb4 --- /dev/null +++ b/weixin-plugin/weixinGateway.ts @@ -0,0 +1,366 @@ +/** + * WeixinGateway + * IM Gateway for Weixin personal accounts via iLink long-polling protocol + */ + +import { EventEmitter } from 'events'; +import { + WeixinConfig, + WeixinGatewayStatus, + IMMessage, + DEFAULT_WEIXIN_STATUS, +} from './types'; +import { + resolveWeixinAccount, + saveWeixinAccount, + registerWeixinAccountId, + DEFAULT_BASE_URL, +} from './weixin/auth/accounts'; +import { setContextToken, getContextToken } from './weixin/messaging/inbound'; +import { sendMessageWeixin } from './weixin/messaging/send'; +import { getUpdates } from './weixin/api/api'; +import { logger } from './weixin/util/logger'; + +const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; +const MAX_CONSECUTIVE_FAILURES = 3; +const BACKOFF_DELAY_MS = 30_000; +const RETRY_DELAY_MS = 2_000; + +interface WeixinAccount { + accountId: string; + baseUrl: string; + cdnBaseUrl: string; + token: string; + userId?: string; +} + +export class WeixinGateway extends EventEmitter { + private config: WeixinConfig | null = null; + private status: WeixinGatewayStatus = { ...DEFAULT_WEIXIN_STATUS }; + private account: WeixinAccount | null = null; + private abortController: AbortController | null = null; + private onMessageCallback?: ( + message: IMMessage, + replyFn: (text: string) => Promise + ) => Promise; + + constructor() { + super(); + } + + async start(config: WeixinConfig): Promise { + if (!config.enabled) return; + if (this.abortController) { + logger.info('[WeixinGateway] Already running, stopping first...'); + await this.stop(); + } + + this.config = config; + + const resolved = resolveWeixinAccount({}, undefined); + if (!resolved.configured || !resolved.token) { + this.status = { + connected: false, + startedAt: null, + lastError: '微信未登录。请先在设置页面扫码登录。', + accountId: null, + lastInboundAt: null, + lastOutboundAt: null, + }; + this.emit('error', new Error(this.status.lastError)); + return; + } + + this.account = { + accountId: resolved.accountId, + baseUrl: resolved.baseUrl, + cdnBaseUrl: resolved.cdnBaseUrl, + token: resolved.token, + userId: resolved.userId, + }; + + this.abortController = new AbortController(); + + this.status = { + connected: true, + startedAt: Date.now(), + lastError: null, + accountId: resolved.accountId, + lastInboundAt: null, + lastOutboundAt: null, + }; + + logger.info(`[WeixinGateway] Connected with account ${resolved.accountId}`); + this.emit('connected'); + + this.runMonitor().catch((err) => { + logger.error(`[WeixinGateway] Monitor ended with error: ${err}`); + }); + } + + async stop(): Promise { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + + this.account = null; + + this.status = { + connected: false, + startedAt: null, + lastError: null, + accountId: null, + lastInboundAt: null, + lastOutboundAt: null, + }; + + logger.info('[WeixinGateway] Stopped'); + this.emit('disconnected'); + } + + async reconnectIfNeeded(): Promise { + if (!this.abortController && this.config?.enabled) { + logger.info('[WeixinGateway] reconnectIfNeeded: restarting...'); + await this.start(this.config!); + } + } + + isConnected(): boolean { + return this.status.connected; + } + + isRunning(): boolean { + return this.abortController !== null; + } + + getStatus(): WeixinGatewayStatus { + return { ...this.status }; + } + + getNotificationTarget(): null { + return null; + } + + setNotificationTarget(_target: unknown): void {} + + setMessageCallback( + callback: (message: IMMessage, replyFn: (text: string) => Promise) => Promise + ): void { + this.onMessageCallback = callback; + } + + async sendNotification(text: string): Promise { + if (!this.account) throw new Error('No account available for notification'); + await this.doSendReply(this.account.userId || this.account.accountId, text); + } + + async sendNotificationWithMedia(_text: string): Promise {} + + updateConfig(config: Partial): void { + this.config = this.config ? { ...this.config, ...config } : (config as WeixinConfig); + } + + private async runMonitor(): Promise { + if (!this.account || !this.abortController) return; + + const { baseUrl, token } = this.account; + let syncBuf = ''; + let nextTimeoutMs = DEFAULT_LONG_POLL_TIMEOUT_MS; + let consecutiveFailures = 0; + + while (!this.abortController.signal.aborted) { + try { + const resp = await getUpdates({ + baseUrl, + token, + get_updates_buf: syncBuf || undefined, + timeoutMs: nextTimeoutMs, + }); + + if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) { + nextTimeoutMs = resp.longpolling_timeout_ms; + } + + const apiRet = resp.ret ?? resp.errcode ?? 0; + if (apiRet !== 0) { + consecutiveFailures++; + logger.error(`[WeixinGateway] getUpdates ret=${apiRet} errmsg=${resp.errmsg ?? ''}`); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + consecutiveFailures = 0; + await this.sleep(BACKOFF_DELAY_MS); + } else { + await this.sleep(RETRY_DELAY_MS); + } + continue; + } + + consecutiveFailures = 0; + this.status.lastError = null; + + if (resp.get_updates_buf != null && resp.get_updates_buf !== '') { + syncBuf = resp.get_updates_buf; + } + + const msgList = resp.msgs ?? []; + for (const msg of msgList) { + await this.handleMessage(msg); + } + + await this.sleep(nextTimeoutMs > 0 ? nextTimeoutMs : 1000); + } catch (err) { + if (this.abortController?.signal.aborted) { + logger.info('[WeixinGateway] Monitor stopped (aborted)'); + return; + } + consecutiveFailures++; + const errMsg = err instanceof Error ? err.message : String(err); + logger.error(`[WeixinGateway] getUpdates error: ${errMsg}`); + this.status.lastError = errMsg; + this.emit('error', err); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + consecutiveFailures = 0; + await this.sleep(BACKOFF_DELAY_MS); + } else { + await this.sleep(RETRY_DELAY_MS); + } + } + } + + logger.info('[WeixinGateway] Monitor ended'); + } + + private async handleMessage(msg: any): Promise { + if (!this.account) return; + + const fromUserId = msg.from_user_id ?? ''; + if (!fromUserId) return; + + if (msg.context_token) { + setContextToken(this.account.accountId, fromUserId, msg.context_token); + } + + const itemList = msg.item_list ?? []; + const content = this.extractTextContent(itemList); + + const message: IMMessage = { + platform: 'weixin', + messageId: String(msg.message_id || `${Date.now()}-${Math.random().toString(36).slice(2)}`), + conversationId: fromUserId, + senderId: fromUserId, + content, + chatType: 'direct', + timestamp: msg.create_time_ms || Date.now(), + }; + + this.status.lastInboundAt = Date.now(); + this.emit('statusChange'); + + const contextToken = getContextToken(this.account.accountId, fromUserId); + + const replyFn = async (text: string): Promise => { + if (!this.account) return; + await sendMessageWeixin({ + to: fromUserId, + text, + opts: { + baseUrl: this.account.baseUrl, + token: this.account.token, + contextToken, + }, + }); + this.status.lastOutboundAt = Date.now(); + }; + + this.emit('message', message); + + if (this.onMessageCallback) { + try { + await this.onMessageCallback(message, replyFn); + } catch (err) { + logger.error(`[WeixinGateway] onMessageCallback error: ${err}`); + } + } + } + + private extractTextContent(itemList?: any[]): string { + if (!itemList?.length) return ''; + for (const item of itemList) { + if (item.type === 1 && item.text_item?.text) { + return item.text_item.text; + } + } + return ''; + } + + private async doSendReply(to: string, text: string, contextToken?: string): Promise { + if (!this.account) return; + await sendMessageWeixin({ + to, + text, + opts: { + baseUrl: this.account.baseUrl, + token: this.account.token, + contextToken, + }, + }); + this.status.lastOutboundAt = Date.now(); + this.emit('statusChange'); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// =========================================================================== +// Login helpers (called by IMGatewayManager) +// =========================================================================== + +export async function startWeixinLogin(accountId?: string): Promise<{ + sessionKey: string; + qrcodeUrl: string; +}> { + const resolved = resolveWeixinAccount({}, accountId); + const result = await import('./weixin/auth/login-qr').then((m) => + m.startWeixinLoginWithQr({ apiBaseUrl: resolved.baseUrl, accountId }) + ); + return { sessionKey: result.sessionKey, qrcodeUrl: result.qrcodeUrl ?? '' }; +} + +export async function waitWeixinLogin( + sessionKey: string, + timeoutMs = 480_000 +): Promise<{ + connected: boolean; + accountId?: string; + botToken?: string; + baseUrl?: string; + userId?: string; + error?: string; +}> { + const result = await import('./weixin/auth/login-qr').then((m) => + m.waitForWeixinLogin({ sessionKey, apiBaseUrl: DEFAULT_BASE_URL, timeoutMs }) + ); + + if (!result.connected) { + return { connected: false, error: result.message }; + } + + const accountId = result.accountId ?? 'default'; + + saveWeixinAccount(accountId, { + token: result.botToken!, + baseUrl: result.baseUrl, + userId: result.userId, + }); + registerWeixinAccountId(accountId); + + return { + connected: true, + accountId, + botToken: result.botToken, + baseUrl: result.baseUrl, + userId: result.userId, + }; +}