feat: 初始化微信插件独立测试环境

添加完整的微信插件测试框架,包含:
- 微信插件源码(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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
zyh
2026-03-30 07:14:00 +00:00
commit 77019ec050
42 changed files with 4849 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -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/

179
README.md Normal file
View File

@@ -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

19
package.json Normal file
View File

@@ -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"
}
}

101
scripts/build-weixin-plugin.sh Executable file
View File

@@ -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"

View File

@@ -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);
});

View File

@@ -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);
});

64
weixin-plugin/types.ts Normal file
View File

@@ -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,
};

View File

@@ -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<string, string> {
const headers: Record<string, string> = {
"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<string> {
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<GetUpdatesResp> {
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<GetUploadUrlResp> {
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<void> {
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<GetConfigResp> {
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<void> {
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",
});
}

View File

@@ -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<string, ConfigCacheEntry>();
constructor(
private apiOpts: { baseUrl: string; token?: string },
private log: (msg: string) => void,
) {}
async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
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: "" };
}
}

View File

@@ -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<string, number>();
/** 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();
}

View File

@@ -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;
/** 缩略图明文 MD5IMAGE/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;
}

View File

@@ -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
}

View File

@@ -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<string, ActiveLogin>();
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<string, string> = {};
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<StatusResponse> {
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
const headers: Record<string, string> = {
"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<WeixinQrStartResult> {
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<WeixinQrWaitResult> {
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: "登录超时,请重试。",
};
}

View File

@@ -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<T>(
filePath: string,
_options: { retries: { retries: number; factor: number; minTimeout: number; maxTimeout: number }; stale: number },
fn: () => Promise<T>
): Promise<T> {
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 };
});
}

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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)}`;
}

View File

@@ -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<Buffer> {
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<Buffer> {
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<Buffer> {
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
logger.debug(`${label}: fetching url=${url}`);
return fetchCdnBytes(url, label);
}

View File

@@ -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<string> {
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<UploadedFileInfo> {
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<UploadedFileInfo> {
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<UploadedFileInfo> {
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<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.FILE,
label: "uploadFileAttachmentToWeixin",
});
}

View File

@@ -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;
}

View File

@@ -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 <url>`. */
logUploadUrl: z.string().optional(),
});

View File

@@ -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<void> {
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}`);
}
}

View File

@@ -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<WeixinInboundMediaOpts> {
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;
}

View File

@@ -0,0 +1,76 @@
import path from "node:path";
const EXTENSION_TO_MIME: Record<string, string> = {
".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<string, string> = {
"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";
}

View File

@@ -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<Buffer | null> {
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;
}
}

View File

@@ -0,0 +1,69 @@
/**
* Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.
*
* State file: `<stateDir>/openclaw-weixin/debug-mode.json`
* Format: `{ "accounts": { "<accountId>": 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<string, boolean>;
}
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
}
}

View File

@@ -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<void> {
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)}`);
}
}

View File

@@ -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<string, string>();
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<string, string> = {};
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<string, string>;
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;
}

View File

@@ -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<void> {
const textBody = extractTextBody(full.item_list);
const fromUserId = full.from_user_id ?? "";
deps.log(`[weixin] inbound message from=${fromUserId} bodyLen=${textBody.length}`);
}

View File

@@ -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 });
}

View File

@@ -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" });
}

View File

@@ -0,0 +1,110 @@
/**
* Weixin 斜杠指令处理模块
*
* 支持的指令:
* - /echo <message> 直接回复消息(不经过 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<void> {
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<void> {
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<SlashCommandResult> {
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 };
}
}

View File

@@ -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;
}

View File

@@ -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<void>;
};
export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
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<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(new Error("aborted"));
},
{ once: true },
);
});
}

View File

@@ -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;
}

View File

@@ -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");
}

View File

@@ -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");
}

View File

@@ -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<string, number> = {
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();

View File

@@ -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}`;
}

View File

@@ -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":"<redacted>"
const redacted = body.replace(
/"(context_token|bot_token|token|authorization|Authorization)"\s*:\s*"[^"]*"/g,
'"$1":"<redacted>"',
);
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}?<redacted>` : base;
} catch {
return truncate(rawUrl, 80);
}
}

25
weixin-plugin/weixin/vendor.d.ts vendored Normal file
View File

@@ -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;
}

View File

@@ -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<void>
) => Promise<void>;
constructor() {
super();
}
async start(config: WeixinConfig): Promise<void> {
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<void> {
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<void> {
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<void>) => Promise<void>
): void {
this.onMessageCallback = callback;
}
async sendNotification(text: string): Promise<void> {
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<void> {}
updateConfig(config: Partial<WeixinConfig>): void {
this.config = this.config ? { ...this.config, ...config } : (config as WeixinConfig);
}
private async runMonitor(): Promise<void> {
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<void> {
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<void> => {
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<void> {
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<void> {
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,
};
}