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:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
179
README.md
Normal 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
19
package.json
Normal 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
101
scripts/build-weixin-plugin.sh
Executable 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"
|
||||
428
scripts/test-weixin-plugin.js
Normal file
428
scripts/test-weixin-plugin.js
Normal 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);
|
||||
});
|
||||
296
scripts/test-weixin-simple.js
Normal file
296
scripts/test-weixin-simple.js
Normal 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
64
weixin-plugin/types.ts
Normal 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,
|
||||
};
|
||||
238
weixin-plugin/weixin/api/api.ts
Normal file
238
weixin-plugin/weixin/api/api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
79
weixin-plugin/weixin/api/config-cache.ts
Normal file
79
weixin-plugin/weixin/api/config-cache.ts
Normal 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: "" };
|
||||
}
|
||||
}
|
||||
58
weixin-plugin/weixin/api/session-guard.ts
Normal file
58
weixin-plugin/weixin/api/session-guard.ts
Normal 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();
|
||||
}
|
||||
220
weixin-plugin/weixin/api/types.ts
Normal file
220
weixin-plugin/weixin/api/types.ts
Normal 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;
|
||||
/** 缩略图明文 MD5(IMAGE/VIDEO 时必填) */
|
||||
thumb_rawfilemd5?: string;
|
||||
/** 缩略图密文大小(IMAGE/VIDEO 时必填) */
|
||||
thumb_filesize?: number;
|
||||
/** 不需要缩略图上传 URL,默认 false */
|
||||
no_need_thumb?: boolean;
|
||||
/** 加密 key */
|
||||
aeskey?: string;
|
||||
}
|
||||
|
||||
export interface GetUploadUrlResp {
|
||||
/** 原图上传加密参数 */
|
||||
upload_param?: string;
|
||||
/** 缩略图上传加密参数,无缩略图时为空 */
|
||||
thumb_upload_param?: string;
|
||||
}
|
||||
|
||||
export const MessageType = {
|
||||
NONE: 0,
|
||||
USER: 1,
|
||||
BOT: 2,
|
||||
} as const;
|
||||
|
||||
export const MessageItemType = {
|
||||
NONE: 0,
|
||||
TEXT: 1,
|
||||
IMAGE: 2,
|
||||
VOICE: 3,
|
||||
FILE: 4,
|
||||
VIDEO: 5,
|
||||
} as const;
|
||||
|
||||
export const MessageState = {
|
||||
NEW: 0,
|
||||
GENERATING: 1,
|
||||
FINISH: 2,
|
||||
} as const;
|
||||
|
||||
export interface TextItem {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** CDN media reference; aes_key is base64-encoded bytes in JSON. */
|
||||
export interface CDNMedia {
|
||||
encrypt_query_param?: string;
|
||||
aes_key?: string;
|
||||
/** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */
|
||||
encrypt_type?: number;
|
||||
}
|
||||
|
||||
export interface ImageItem {
|
||||
/** 原图 CDN 引用 */
|
||||
media?: CDNMedia;
|
||||
/** 缩略图 CDN 引用 */
|
||||
thumb_media?: CDNMedia;
|
||||
/** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */
|
||||
aeskey?: string;
|
||||
url?: string;
|
||||
mid_size?: number;
|
||||
thumb_size?: number;
|
||||
thumb_height?: number;
|
||||
thumb_width?: number;
|
||||
hd_size?: number;
|
||||
}
|
||||
|
||||
export interface VoiceItem {
|
||||
media?: CDNMedia;
|
||||
/** 语音编码类型:1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */
|
||||
encode_type?: number;
|
||||
bits_per_sample?: number;
|
||||
/** 采样率 (Hz) */
|
||||
sample_rate?: number;
|
||||
/** 语音长度 (毫秒) */
|
||||
playtime?: number;
|
||||
/** 语音转文字内容 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
media?: CDNMedia;
|
||||
file_name?: string;
|
||||
md5?: string;
|
||||
len?: string;
|
||||
}
|
||||
|
||||
export interface VideoItem {
|
||||
media?: CDNMedia;
|
||||
video_size?: number;
|
||||
play_length?: number;
|
||||
video_md5?: string;
|
||||
thumb_media?: CDNMedia;
|
||||
thumb_size?: number;
|
||||
thumb_height?: number;
|
||||
thumb_width?: number;
|
||||
}
|
||||
|
||||
export interface RefMessage {
|
||||
message_item?: MessageItem;
|
||||
title?: string; // 摘要
|
||||
}
|
||||
|
||||
export interface MessageItem {
|
||||
type?: number;
|
||||
create_time_ms?: number;
|
||||
update_time_ms?: number;
|
||||
is_completed?: boolean;
|
||||
msg_id?: string;
|
||||
ref_msg?: RefMessage;
|
||||
text_item?: TextItem;
|
||||
image_item?: ImageItem;
|
||||
voice_item?: VoiceItem;
|
||||
file_item?: FileItem;
|
||||
video_item?: VideoItem;
|
||||
}
|
||||
|
||||
/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
|
||||
export interface WeixinMessage {
|
||||
seq?: number;
|
||||
message_id?: number;
|
||||
from_user_id?: string;
|
||||
to_user_id?: string;
|
||||
client_id?: string;
|
||||
create_time_ms?: number;
|
||||
update_time_ms?: number;
|
||||
delete_time_ms?: number;
|
||||
session_id?: string;
|
||||
group_id?: string;
|
||||
message_type?: number;
|
||||
message_state?: number;
|
||||
item_list?: MessageItem[];
|
||||
context_token?: string;
|
||||
}
|
||||
|
||||
/** GetUpdates request: bytes fields are base64 strings in JSON. */
|
||||
export interface GetUpdatesReq {
|
||||
/** @deprecated compat only, will be removed */
|
||||
sync_buf?: string;
|
||||
/** Full context buf cached locally; send "" when none (first request or after reset). */
|
||||
get_updates_buf?: string;
|
||||
}
|
||||
|
||||
/** GetUpdates response: bytes fields are base64 strings in JSON. */
|
||||
export interface GetUpdatesResp {
|
||||
ret?: number;
|
||||
/** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
msgs?: WeixinMessage[];
|
||||
/** @deprecated compat only */
|
||||
sync_buf?: string;
|
||||
/** Full context buf to cache locally and send on next request. */
|
||||
get_updates_buf?: string;
|
||||
/** Server-suggested timeout (ms) for the next getUpdates long-poll. */
|
||||
longpolling_timeout_ms?: number;
|
||||
}
|
||||
|
||||
/** SendMessage request: wraps a single WeixinMessage. */
|
||||
export interface SendMessageReq {
|
||||
msg?: WeixinMessage;
|
||||
}
|
||||
|
||||
export type SendMessageResp = object;
|
||||
|
||||
/** Typing status: 1 = typing (default), 2 = cancel typing. */
|
||||
export const TypingStatus = {
|
||||
TYPING: 1,
|
||||
CANCEL: 2,
|
||||
} as const;
|
||||
|
||||
/** SendTyping request: send a typing indicator to a user. */
|
||||
export interface SendTypingReq {
|
||||
ilink_user_id?: string;
|
||||
typing_ticket?: string;
|
||||
/** 1=typing (default), 2=cancel typing */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface SendTypingResp {
|
||||
ret?: number;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
/** GetConfig response: bot config including typing_ticket. */
|
||||
export interface GetConfigResp {
|
||||
ret?: number;
|
||||
errmsg?: string;
|
||||
/** Base64-encoded typing ticket for sendTyping. */
|
||||
typing_ticket?: string;
|
||||
}
|
||||
213
weixin-plugin/weixin/auth/accounts.ts
Normal file
213
weixin-plugin/weixin/auth/accounts.ts
Normal 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
|
||||
}
|
||||
328
weixin-plugin/weixin/auth/login-qr.ts
Normal file
328
weixin-plugin/weixin/auth/login-qr.ts
Normal 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: "登录超时,请重试。",
|
||||
};
|
||||
}
|
||||
126
weixin-plugin/weixin/auth/pairing.ts
Normal file
126
weixin-plugin/weixin/auth/pairing.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
21
weixin-plugin/weixin/cdn/aes-ecb.ts
Normal file
21
weixin-plugin/weixin/cdn/aes-ecb.ts
Normal 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;
|
||||
}
|
||||
77
weixin-plugin/weixin/cdn/cdn-upload.ts
Normal file
77
weixin-plugin/weixin/cdn/cdn-upload.ts
Normal 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 };
|
||||
}
|
||||
17
weixin-plugin/weixin/cdn/cdn-url.ts
Normal file
17
weixin-plugin/weixin/cdn/cdn-url.ts
Normal 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)}`;
|
||||
}
|
||||
85
weixin-plugin/weixin/cdn/pic-decrypt.ts
Normal file
85
weixin-plugin/weixin/cdn/pic-decrypt.ts
Normal 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);
|
||||
}
|
||||
155
weixin-plugin/weixin/cdn/upload.ts
Normal file
155
weixin-plugin/weixin/cdn/upload.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
81
weixin-plugin/weixin/channel.ts
Normal file
81
weixin-plugin/weixin/channel.ts
Normal 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;
|
||||
}
|
||||
22
weixin-plugin/weixin/config/config-schema.ts
Normal file
22
weixin-plugin/weixin/config/config-schema.ts
Normal 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(),
|
||||
});
|
||||
67
weixin-plugin/weixin/log-upload.ts
Normal file
67
weixin-plugin/weixin/log-upload.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
141
weixin-plugin/weixin/media/media-download.ts
Normal file
141
weixin-plugin/weixin/media/media-download.ts
Normal 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;
|
||||
}
|
||||
76
weixin-plugin/weixin/media/mime.ts
Normal file
76
weixin-plugin/weixin/media/mime.ts
Normal 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";
|
||||
}
|
||||
74
weixin-plugin/weixin/media/silk-transcode.ts
Normal file
74
weixin-plugin/weixin/media/silk-transcode.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
69
weixin-plugin/weixin/messaging/debug-mode.ts
Normal file
69
weixin-plugin/weixin/messaging/debug-mode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
weixin-plugin/weixin/messaging/error-notice.ts
Normal file
30
weixin-plugin/weixin/messaging/error-notice.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
262
weixin-plugin/weixin/messaging/inbound.ts
Normal file
262
weixin-plugin/weixin/messaging/inbound.ts
Normal 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;
|
||||
}
|
||||
33
weixin-plugin/weixin/messaging/process-message.ts
Normal file
33
weixin-plugin/weixin/messaging/process-message.ts
Normal 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}`);
|
||||
}
|
||||
72
weixin-plugin/weixin/messaging/send-media.ts
Normal file
72
weixin-plugin/weixin/messaging/send-media.ts
Normal 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 });
|
||||
}
|
||||
209
weixin-plugin/weixin/messaging/send.ts
Normal file
209
weixin-plugin/weixin/messaging/send.ts
Normal 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" });
|
||||
}
|
||||
110
weixin-plugin/weixin/messaging/slash-commands.ts
Normal file
110
weixin-plugin/weixin/messaging/slash-commands.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
27
weixin-plugin/weixin/messaging/strip-markdown.ts
Normal file
27
weixin-plugin/weixin/messaging/strip-markdown.ts
Normal 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;
|
||||
}
|
||||
158
weixin-plugin/weixin/monitor/monitor.ts
Normal file
158
weixin-plugin/weixin/monitor/monitor.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
22
weixin-plugin/weixin/runtime.ts
Normal file
22
weixin-plugin/weixin/runtime.ts
Normal 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;
|
||||
}
|
||||
15
weixin-plugin/weixin/storage/state-dir.ts
Normal file
15
weixin-plugin/weixin/storage/state-dir.ts
Normal 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");
|
||||
}
|
||||
46
weixin-plugin/weixin/storage/sync-buf.ts
Normal file
46
weixin-plugin/weixin/storage/sync-buf.ts
Normal 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");
|
||||
}
|
||||
141
weixin-plugin/weixin/util/logger.ts
Normal file
141
weixin-plugin/weixin/util/logger.ts
Normal 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();
|
||||
17
weixin-plugin/weixin/util/random.ts
Normal file
17
weixin-plugin/weixin/util/random.ts
Normal 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}`;
|
||||
}
|
||||
54
weixin-plugin/weixin/util/redact.ts
Normal file
54
weixin-plugin/weixin/util/redact.ts
Normal 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
25
weixin-plugin/weixin/vendor.d.ts
vendored
Normal 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;
|
||||
}
|
||||
366
weixin-plugin/weixinGateway.ts
Normal file
366
weixin-plugin/weixinGateway.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user