wechat-opencode-bot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/bin/start.bat +93 -0
- package/bin/start.sh +78 -0
- package/package.json +41 -0
- package/src/bridge.js +659 -0
- package/src/cli.js +221 -0
- package/src/paths.js +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qiqi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# wechat-opencode-bot
|
|
2
|
+
|
|
3
|
+
微信 ⇄ opencode 桥接机器人。微信消息通过 opencode serve 驱动 AI 回复,支持定时任务主动推送、图片/文件收发。跨平台(macOS / Windows / Linux)。
|
|
4
|
+
|
|
5
|
+
## 原理
|
|
6
|
+
|
|
7
|
+
cc-wechat 的 `notifications/claude/channel` 是 Claude Code 专属协议,opencode 不处理。本工具由桥接进程独占轮询微信 iLink API,拿到消息后通过 opencode serve 的 HTTP API 触发 AI 回复,再回传微信。
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
微信用户 ⇄ iLink API ⇄ bridge(独占轮询) ⇄ opencode serve(HTTP) ⇄ LLM
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g wechat-opencode-bot
|
|
17
|
+
# 首次登录微信(扫码)
|
|
18
|
+
wechat-bot login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
依赖 `cc-wechat`(自动安装)。需要 `opencode` 在 PATH 中,或设置 `OPENCODE_BIN` 指向其可执行文件。
|
|
22
|
+
|
|
23
|
+
## 快速开始
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
wechat-bot serve # 启动 opencode serve(前台)
|
|
27
|
+
wechat-bot bridge # 启动桥接进程(前台)
|
|
28
|
+
wechat-bot status # 查看状态
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
或使用自启动:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
wechat-bot install # macOS 生成 launchd plist 并加载
|
|
35
|
+
./bin/start.sh start # 或手动启动脚本(macOS/Linux)
|
|
36
|
+
./bin/start.bat start# Windows
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 环境变量
|
|
40
|
+
|
|
41
|
+
| 变量 | 默认值 | 说明 |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `WECHAT_BOT_HOME` | `~/.wechat-bot` | 数据目录(sessions/tasks/media/logs/pids) |
|
|
44
|
+
| `WECHAT_STATE_DIR` | `~/.claude/channels/wechat/<profile>` | cc-wechat 状态目录 |
|
|
45
|
+
| `WECHAT_PROFILE` | `default` | cc-wechat 账号 profile |
|
|
46
|
+
| `OPENCODE_SERVER_URL` | `http://127.0.0.1:4100` | opencode serve 地址 |
|
|
47
|
+
| `OPENCODE_PORT` | `4100` | opencode serve 端口 |
|
|
48
|
+
| `OPENCODE_BIN` | `opencode` | opencode 可执行文件 |
|
|
49
|
+
| `LLM_TIMEOUT_MS` | `300000` | LLM 响应超时 |
|
|
50
|
+
| `BOT_PREFIX` | 空 | 仅响应带此前缀的消息 |
|
|
51
|
+
|
|
52
|
+
## 功能
|
|
53
|
+
|
|
54
|
+
- **自动回复**:每用户独立 opencode session(会话历史持久化)。
|
|
55
|
+
- **定时任务**:对 bot 说"每天早上9点提醒我喝水",AI 输出 `[schedule: 09:00 | 提醒喝水]` 标记,bridge 解析存储,到点主动推送。
|
|
56
|
+
- 时间表达式:`HH:MM`(每天) / `每N小时` / `每N分钟` / `YYYY-MM-DD HH:MM`(一次性)
|
|
57
|
+
- 查看/删除:`[schedule_list]` / `[schedule_del: id]`
|
|
58
|
+
- **图片/文件**:接收时下载到本地供 AI 读取(`[图片: 路径]`);发送时用 `[media: /绝对/路径]` 标记上传。
|
|
59
|
+
- **自动重连**:网络错误指数退避,session 过期自动提示重新登录。
|
|
60
|
+
|
|
61
|
+
## 数据文件
|
|
62
|
+
|
|
63
|
+
- `sessions.json`:微信用户 → opencode session 映射
|
|
64
|
+
- `tasks.json`:定时任务
|
|
65
|
+
- `media/`:接收的媒体文件
|
|
66
|
+
- `logs/`:serve/bridge 日志
|
|
67
|
+
|
|
68
|
+
## 开发
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm install
|
|
72
|
+
node src/cli.js bridge # 直接运行
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 手动部署说明(Windows 自启)
|
|
76
|
+
|
|
77
|
+
Windows 建议用任务计划程序(schtasks)或 NSSM 注册为服务:
|
|
78
|
+
|
|
79
|
+
```bat
|
|
80
|
+
schtasks /create /tn wechat-serve /tr "node C:\...\cli.js serve" /sc onstart
|
|
81
|
+
schtasks /create /tn wechat-bridge /tr "node C:\...\cli.js bridge" /sc onstart
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/bin/start.bat
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM opencode-wechat-bot 启动脚本(Windows)
|
|
3
|
+
REM 用法: start.bat [start|stop|restart|status]
|
|
4
|
+
|
|
5
|
+
setlocal
|
|
6
|
+
set DIR=%~dp0
|
|
7
|
+
for %%I in ("%DIR%..") do set "PKG_DIR=%%~fI"
|
|
8
|
+
|
|
9
|
+
if "%WECHAT_BOT_HOME%"=="" (
|
|
10
|
+
set "WECHAT_BOT_HOME=%USERPROFILE%\.wechat-bot"
|
|
11
|
+
)
|
|
12
|
+
set "LOG_DIR=%WECHAT_BOT_HOME%\logs"
|
|
13
|
+
set "PID_DIR=%WECHAT_BOT_HOME%\pids"
|
|
14
|
+
set "SERVE_PID_FILE=%PID_DIR%\serve.pid"
|
|
15
|
+
set "BRIDGE_PID_FILE=%PID_DIR%\bridge.pid"
|
|
16
|
+
|
|
17
|
+
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
|
18
|
+
if not exist "%PID_DIR%" mkdir "%PID_DIR%"
|
|
19
|
+
|
|
20
|
+
set CMD=%1
|
|
21
|
+
if "%CMD%"=="" set CMD=start
|
|
22
|
+
|
|
23
|
+
goto :%CMD%
|
|
24
|
+
goto :usage
|
|
25
|
+
|
|
26
|
+
:is_running
|
|
27
|
+
set "PFILE=%~1"
|
|
28
|
+
if not exist "%PFILE%" exit /b 1
|
|
29
|
+
set /p PID=<"%PFILE%"
|
|
30
|
+
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
|
31
|
+
if errorlevel 1 exit /b 1
|
|
32
|
+
exit /b 0
|
|
33
|
+
|
|
34
|
+
:start_serve
|
|
35
|
+
call :is_running "%SERVE_PID_FILE%"
|
|
36
|
+
if not errorlevel 1 (
|
|
37
|
+
echo serve already running
|
|
38
|
+
goto :eof
|
|
39
|
+
)
|
|
40
|
+
set "OPENCODE_CONFIG_CONTENT={"mcp":{"wechat":{"enabled":false}}}"
|
|
41
|
+
set "PORT=%OPENCODE_PORT%"
|
|
42
|
+
if "%PORT%"=="" set PORT=4100
|
|
43
|
+
powershell -NoProfile -Command "Start-Process -FilePath 'node' -ArgumentList '\"%PKG_DIR%\src\cli.js\"','serve' -WindowStyle Hidden -RedirectStandardOutput '%LOG_DIR%\serve.log' -RedirectStandardError '%LOG_DIR%\serve.err.log' -PassThru | Select-Object -ExpandProperty Id | Out-File -Encoding ascii '%SERVE_PID_FILE%'"
|
|
44
|
+
echo serve started (port %PORT%)
|
|
45
|
+
goto :eof
|
|
46
|
+
|
|
47
|
+
:start_bridge
|
|
48
|
+
call :is_running "%BRIDGE_PID_FILE%"
|
|
49
|
+
if not errorlevel 1 (
|
|
50
|
+
echo bridge already running
|
|
51
|
+
goto :eof
|
|
52
|
+
)
|
|
53
|
+
powershell -NoProfile -Command "Start-Process -FilePath 'node' -ArgumentList '\"%PKG_DIR%\src\cli.js\"','bridge' -WindowStyle Hidden -RedirectStandardOutput '%LOG_DIR%\bridge.log' -RedirectStandardError '%LOG_DIR%\bridge.err.log' -PassThru | Select-Object -ExpandProperty Id | Out-File -Encoding ascii '%BRIDGE_PID_FILE%'"
|
|
54
|
+
echo bridge started
|
|
55
|
+
goto :eof
|
|
56
|
+
|
|
57
|
+
:start
|
|
58
|
+
call :start_serve
|
|
59
|
+
call :start_bridge
|
|
60
|
+
goto :eof
|
|
61
|
+
|
|
62
|
+
:stop
|
|
63
|
+
set "PFILE=%BRIDGE_PID_FILE%"
|
|
64
|
+
if exist "%PFILE%" (
|
|
65
|
+
set /p PID=<"%PFILE%"
|
|
66
|
+
taskkill /PID %PID% /F >nul 2>&1
|
|
67
|
+
del "%PFILE%"
|
|
68
|
+
echo bridge stopped
|
|
69
|
+
)
|
|
70
|
+
set "PFILE=%SERVE_PID_FILE%"
|
|
71
|
+
if exist "%PFILE%" (
|
|
72
|
+
set /p PID=<"%PFILE%"
|
|
73
|
+
taskkill /PID %PID% /F >nul 2>&1
|
|
74
|
+
del "%PFILE%"
|
|
75
|
+
echo serve stopped
|
|
76
|
+
)
|
|
77
|
+
goto :eof
|
|
78
|
+
|
|
79
|
+
:restart
|
|
80
|
+
call :stop
|
|
81
|
+
call :start
|
|
82
|
+
goto :eof
|
|
83
|
+
|
|
84
|
+
:status
|
|
85
|
+
call :is_running "%SERVE_PID_FILE%"
|
|
86
|
+
if not errorlevel 1 (echo serve: running) else (echo serve: not running)
|
|
87
|
+
call :is_running "%BRIDGE_PID_FILE%"
|
|
88
|
+
if not errorlevel 1 (echo bridge: running) else (echo bridge: not running)
|
|
89
|
+
goto :eof
|
|
90
|
+
|
|
91
|
+
:usage
|
|
92
|
+
echo Usage: start.bat [start^|stop^|restart^|status]
|
|
93
|
+
exit /b 1
|
package/bin/start.sh
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# opencode-wechat-bot 启动脚本(macOS / Linux)
|
|
3
|
+
# 用法: ./start.sh [start|stop|restart|status]
|
|
4
|
+
set -u
|
|
5
|
+
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
+
PKG_DIR="$(cd "$DIR/.." && pwd)"
|
|
7
|
+
PORT="${OPENCODE_PORT:-4100}"
|
|
8
|
+
LOG_DIR="${WECHAT_BOT_HOME:-$HOME/.wechat-bot}/logs"
|
|
9
|
+
PID_DIR="${WECHAT_BOT_HOME:-$HOME/.wechat-bot}/pids"
|
|
10
|
+
SERVE_PID_FILE="$PID_DIR/serve.pid"
|
|
11
|
+
BRIDGE_PID_FILE="$PID_DIR/bridge.pid"
|
|
12
|
+
|
|
13
|
+
mkdir -p "$LOG_DIR" "$PID_DIR"
|
|
14
|
+
|
|
15
|
+
is_running() {
|
|
16
|
+
local pid_file="$1"
|
|
17
|
+
[ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
start_serve() {
|
|
21
|
+
if is_running "$SERVE_PID_FILE"; then
|
|
22
|
+
echo "serve 已在运行 (PID $(cat "$SERVE_PID_FILE"))"
|
|
23
|
+
return
|
|
24
|
+
fi
|
|
25
|
+
OPENCODE_CONFIG_CONTENT='{"mcp":{"wechat":{"enabled":false}}}' \
|
|
26
|
+
nohup node "$PKG_DIR/src/cli.js" serve > "$LOG_DIR/serve.log" 2>&1 &
|
|
27
|
+
echo $! > "$SERVE_PID_FILE"
|
|
28
|
+
echo "serve 已启动 (PID $!) port=$PORT"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start_bridge() {
|
|
32
|
+
if is_running "$BRIDGE_PID_FILE"; then
|
|
33
|
+
echo "bridge 已在运行 (PID $(cat "$BRIDGE_PID_FILE"))"
|
|
34
|
+
return
|
|
35
|
+
fi
|
|
36
|
+
nohup node "$PKG_DIR/src/cli.js" bridge > "$LOG_DIR/bridge.log" 2>&1 &
|
|
37
|
+
echo $! > "$BRIDGE_PID_FILE"
|
|
38
|
+
echo "bridge 已启动 (PID $!)"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop_process() {
|
|
42
|
+
local pid_file="$1"
|
|
43
|
+
local name="$2"
|
|
44
|
+
if [ -f "$pid_file" ]; then
|
|
45
|
+
local pid
|
|
46
|
+
pid="$(cat "$pid_file")"
|
|
47
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
48
|
+
kill "$pid" 2>/dev/null
|
|
49
|
+
echo "$name 已停止 (PID $pid)"
|
|
50
|
+
else
|
|
51
|
+
echo "$name 未在运行"
|
|
52
|
+
fi
|
|
53
|
+
rm -f "$pid_file"
|
|
54
|
+
fi
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case "${1:-start}" in
|
|
58
|
+
start)
|
|
59
|
+
start_serve
|
|
60
|
+
start_bridge
|
|
61
|
+
;;
|
|
62
|
+
stop)
|
|
63
|
+
stop_process "$BRIDGE_PID_FILE" "bridge"
|
|
64
|
+
stop_process "$SERVE_PID_FILE" "serve"
|
|
65
|
+
;;
|
|
66
|
+
restart)
|
|
67
|
+
"$0" stop
|
|
68
|
+
sleep 1
|
|
69
|
+
"$0" start
|
|
70
|
+
;;
|
|
71
|
+
status)
|
|
72
|
+
if is_running "$SERVE_PID_FILE"; then echo "serve: 运行中 (PID $(cat "$SERVE_PID_FILE"))"; else echo "serve: 未运行"; fi
|
|
73
|
+
if is_running "$BRIDGE_PID_FILE"; then echo "bridge: 运行中 (PID $(cat "$BRIDGE_PID_FILE"))"; else echo "bridge: 未运行"; fi
|
|
74
|
+
;;
|
|
75
|
+
*)
|
|
76
|
+
echo "用法: $0 [start|stop|restart|status]"
|
|
77
|
+
;;
|
|
78
|
+
esac
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wechat-opencode-bot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "微信 ⇄ opencode 桥接机器人:微信消息通过 opencode serve 驱动 AI 回复,支持定时任务主动推送、媒体收发,跨平台(macOS/Windows/Linux)。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wechat-bot": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/bridge.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"bin",
|
|
15
|
+
"config",
|
|
16
|
+
"launchd",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node src/cli.js bridge",
|
|
22
|
+
"serve": "node src/cli.js serve",
|
|
23
|
+
"login": "node src/cli.js login",
|
|
24
|
+
"status": "node src/cli.js status"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"wechat",
|
|
31
|
+
"weixin",
|
|
32
|
+
"opencode",
|
|
33
|
+
"bot",
|
|
34
|
+
"ilink",
|
|
35
|
+
"bridge"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"cc-wechat": "0.6.8"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wechat-bridge — 微信 ⇄ opencode serve 桥接守护进程
|
|
3
|
+
*
|
|
4
|
+
* 架构: 微信用户 ⇄ iLink API ⇄ 本进程(独占轮询) ⇄ opencode serve(HTTP) ⇄ LLM
|
|
5
|
+
*
|
|
6
|
+
* 依赖: 复用 cc-wechat 包的 ilink-api.js / store.js / text-utils.js / types.js
|
|
7
|
+
* 说明: cc-wechat 的 notifications/claude/channel 是 Claude Code 专属协议,
|
|
8
|
+
* opencode 不处理该通知,因此由本进程独占轮询并在拿到消息后
|
|
9
|
+
* 通过 opencode serve 的 HTTP API 触发 AI 回复。
|
|
10
|
+
*/
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import {
|
|
14
|
+
findCcWechatDir,
|
|
15
|
+
STATE_DIR,
|
|
16
|
+
SESSION_MAP_FILE,
|
|
17
|
+
MEDIA_DIR,
|
|
18
|
+
TASKS_FILE,
|
|
19
|
+
SERVER_BASE,
|
|
20
|
+
} from './paths.js';
|
|
21
|
+
|
|
22
|
+
const CC_WECHAT_DIR = findCcWechatDir();
|
|
23
|
+
|
|
24
|
+
const { getActiveAccount, loadSyncBuf, saveSyncBuf } = await import(
|
|
25
|
+
join(CC_WECHAT_DIR, 'dist', 'store.js')
|
|
26
|
+
);
|
|
27
|
+
const { getUpdates, sendMessage, sendTyping, getConfig } = await import(
|
|
28
|
+
join(CC_WECHAT_DIR, 'dist', 'ilink-api.js')
|
|
29
|
+
);
|
|
30
|
+
const { stripMarkdown, chunkText } = await import(
|
|
31
|
+
join(CC_WECHAT_DIR, 'dist', 'text-utils.js')
|
|
32
|
+
);
|
|
33
|
+
const { MessageItemType } = await import(
|
|
34
|
+
join(CC_WECHAT_DIR, 'dist', 'types.js')
|
|
35
|
+
);
|
|
36
|
+
const { downloadMedia, uploadMedia } = await import(
|
|
37
|
+
join(CC_WECHAT_DIR, 'dist', 'cdn.js')
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ─── 常量 ─────────────────────────────────────────────
|
|
41
|
+
const SESSION_EXPIRED_ERRCODE = -14;
|
|
42
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
43
|
+
const INITIAL_RETRY_DELAY_MS = 2_000;
|
|
44
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
45
|
+
const SESSION_PAUSE_MS = 5 * 60_000;
|
|
46
|
+
|
|
47
|
+
// ─── 可配置项(环境变量) ─────────────────────────────
|
|
48
|
+
const MAX_LLM_RESPONSE_TIME_MS = Number(process.env.LLM_TIMEOUT_MS || 300_000);
|
|
49
|
+
const TRIGGER_PREFIX = process.env.BOT_PREFIX || ''; // 可选: 仅响应带前缀的消息
|
|
50
|
+
|
|
51
|
+
// ─── 状态 ─────────────────────────────────────────────
|
|
52
|
+
let pollingActive = false;
|
|
53
|
+
let pollingAbort = null;
|
|
54
|
+
const sessionMap = loadSessionMap();
|
|
55
|
+
const typingTicketCache = new Map();
|
|
56
|
+
const processedIds = new Set();
|
|
57
|
+
let account = null;
|
|
58
|
+
|
|
59
|
+
function loadSessionMap() {
|
|
60
|
+
try {
|
|
61
|
+
if (existsSync(SESSION_MAP_FILE)) {
|
|
62
|
+
return JSON.parse(readFileSync(SESSION_MAP_FILE, 'utf-8'));
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function saveSessionMap() {
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(SESSION_MAP_FILE, JSON.stringify(sessionMap, null, 2), 'utf-8');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
process.stderr.write(`[bridge] 保存 session 映射失败: ${String(err)}\n`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── 定时任务 ─────────────────────────────────────────
|
|
77
|
+
let tasks = loadTasks();
|
|
78
|
+
let schedulerTimer = null;
|
|
79
|
+
|
|
80
|
+
function loadTasks() {
|
|
81
|
+
try {
|
|
82
|
+
if (existsSync(TASKS_FILE)) {
|
|
83
|
+
return JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function saveTasks() {
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2), 'utf-8');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
process.stderr.write(`[bridge] 保存任务失败: ${String(err)}\n`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 解析时间表达式。支持: HH:MM / 每N小时 / 每N分钟 / YYYY-MM-DD HH:MM */
|
|
98
|
+
function parseScheduleExpr(expr) {
|
|
99
|
+
expr = String(expr || '').trim();
|
|
100
|
+
if (!expr) return null;
|
|
101
|
+
|
|
102
|
+
// 每N小时 / 每N分钟
|
|
103
|
+
let m = expr.match(/^每\s*(\d+)\s*(小时|分钟|天)$/);
|
|
104
|
+
if (m) {
|
|
105
|
+
const n = Number(m[1]);
|
|
106
|
+
const unit = m[2];
|
|
107
|
+
if (unit === '小时') return { type: 'interval', minutes: n * 60 };
|
|
108
|
+
if (unit === '分钟') return { type: 'interval', minutes: n };
|
|
109
|
+
if (unit === '天') return { type: 'interval', minutes: n * 1440 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// HH:MM (每天)
|
|
113
|
+
m = expr.match(/^(\d{1,2}):(\d{2})$/);
|
|
114
|
+
if (m) {
|
|
115
|
+
return { type: 'daily', hour: Number(m[1]), minute: Number(m[2]) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// YYYY-MM-DD HH:MM (一次性)
|
|
119
|
+
m = expr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$/);
|
|
120
|
+
if (m) {
|
|
121
|
+
return {
|
|
122
|
+
type: 'once',
|
|
123
|
+
at: new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5])),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 生成任务 ID */
|
|
131
|
+
function nextTaskId() {
|
|
132
|
+
return `t${Date.now()}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 添加任务。返回 { ok, task?, error? } */
|
|
136
|
+
function addTask(userId, expr, prompt) {
|
|
137
|
+
const parsed = parseScheduleExpr(expr);
|
|
138
|
+
if (!parsed) {
|
|
139
|
+
return { ok: false, error: `无法理解时间表达式: ${expr}` };
|
|
140
|
+
}
|
|
141
|
+
const task = { id: nextTaskId(), userId, expr: expr.trim(), parsed, prompt, lastFired: null };
|
|
142
|
+
tasks.push(task);
|
|
143
|
+
saveTasks();
|
|
144
|
+
return { ok: true, task };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 删除任务 */
|
|
148
|
+
function deleteTask(id) {
|
|
149
|
+
const before = tasks.length;
|
|
150
|
+
tasks = tasks.filter((t) => t.id !== id);
|
|
151
|
+
if (tasks.length !== before) {
|
|
152
|
+
saveTasks();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 列出某用户的任务 */
|
|
159
|
+
function listTasksFor(userId) {
|
|
160
|
+
return tasks.filter((t) => t.userId === userId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 检查并触发到期任务 */
|
|
164
|
+
async function runScheduler() {
|
|
165
|
+
const now = new Date();
|
|
166
|
+
const due = [];
|
|
167
|
+
for (const task of tasks) {
|
|
168
|
+
if (!task.enabled && task.enabled !== undefined) continue;
|
|
169
|
+
if (task.parsed.type === 'daily') {
|
|
170
|
+
if (now.getHours() === task.parsed.hour && now.getMinutes() === task.parsed.minute) {
|
|
171
|
+
const todayKey = now.toDateString();
|
|
172
|
+
if (task.lastFired !== todayKey) {
|
|
173
|
+
due.push(task);
|
|
174
|
+
task.lastFired = todayKey;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else if (task.parsed.type === 'interval') {
|
|
178
|
+
const last = task.lastFired ? new Date(task.lastFired) : null;
|
|
179
|
+
if (!last || (now.getTime() - last.getTime()) >= task.parsed.minutes * 60_000) {
|
|
180
|
+
due.push(task);
|
|
181
|
+
task.lastFired = now.toISOString();
|
|
182
|
+
}
|
|
183
|
+
} else if (task.parsed.type === 'once') {
|
|
184
|
+
if (!task.lastFired && now >= task.parsed.at) {
|
|
185
|
+
due.push(task);
|
|
186
|
+
task.lastFired = now.toISOString();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (due.length) saveTasks();
|
|
191
|
+
for (const task of due) {
|
|
192
|
+
try {
|
|
193
|
+
await fireTask(task);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log(`定时任务执行失败 ${task.id}: ${String(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 执行一个定时任务:调该用户 session 生成内容并主动推送 */
|
|
201
|
+
async function fireTask(task) {
|
|
202
|
+
log(`触发定时任务 ${task.id} for=${task.userId}: ${task.prompt}`);
|
|
203
|
+
const sessionId = await getSessionForUser(task.userId);
|
|
204
|
+
const prompt = `【定时任务触发】${task.prompt}\n请据此生成一条发给用户的微信消息。`;
|
|
205
|
+
let reply = '';
|
|
206
|
+
try {
|
|
207
|
+
reply = await askOpenCode(sessionId, prompt);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
reply = `[定时任务执行失败: ${String(err.message ?? err)}]`;
|
|
210
|
+
}
|
|
211
|
+
if (reply) {
|
|
212
|
+
const plainText = stripMarkdown(reply);
|
|
213
|
+
const chunks = chunkText(plainText, 3900);
|
|
214
|
+
for (const chunk of chunks) {
|
|
215
|
+
await sendMessage(account.token, task.userId, chunk, '', account.baseUrl, undefined);
|
|
216
|
+
}
|
|
217
|
+
log(`定时任务已推送 ${task.id} chunks=${chunks.length}`);
|
|
218
|
+
}
|
|
219
|
+
// 一次性任务执行后移除
|
|
220
|
+
if (task.parsed.type === 'once') {
|
|
221
|
+
deleteTask(task.id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 解析 AI 回复中的任务管理标记,返回处理后的文本 */
|
|
226
|
+
function processScheduleMarks(reply, userId) {
|
|
227
|
+
let text = reply;
|
|
228
|
+
const results = [];
|
|
229
|
+
|
|
230
|
+
// [schedule: 表达式 | 提示]
|
|
231
|
+
const addRegex = /\[schedule:\s*([^|\]]+)\s*\|\s*([^\]]+)\]/g;
|
|
232
|
+
let m;
|
|
233
|
+
while ((m = addRegex.exec(reply)) !== null) {
|
|
234
|
+
const expr = m[1].trim();
|
|
235
|
+
const prompt = m[2].trim();
|
|
236
|
+
const r = addTask(userId, expr, prompt);
|
|
237
|
+
results.push(r.ok ? `已添加定时任务: ${prompt} (${expr})` : `添加失败: ${r.error}`);
|
|
238
|
+
text = text.replace(m[0], '');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// [schedule_list]
|
|
242
|
+
if (/\[schedule_list\]/i.test(reply)) {
|
|
243
|
+
const list = listTasksFor(userId);
|
|
244
|
+
if (list.length === 0) {
|
|
245
|
+
results.push('当前没有定时任务');
|
|
246
|
+
} else {
|
|
247
|
+
results.push('当前定时任务:\n' + list.map((t) => `${t.id}: ${t.expr} — ${t.prompt}`).join('\n'));
|
|
248
|
+
}
|
|
249
|
+
text = text.replace(/\[schedule_list\]/gi, '');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// [schedule_del: id]
|
|
253
|
+
const delRegex = /\[schedule_del:\s*([^\]]+)\]/g;
|
|
254
|
+
while ((m = delRegex.exec(reply)) !== null) {
|
|
255
|
+
const id = m[1].trim();
|
|
256
|
+
const ok = deleteTask(id);
|
|
257
|
+
results.push(ok ? `已删除任务 ${id}` : `任务 ${id} 不存在`);
|
|
258
|
+
text = text.replace(m[0], '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (results.length) {
|
|
262
|
+
text = (text + '\n\n' + results.join('\n')).trim();
|
|
263
|
+
}
|
|
264
|
+
return text;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── 日志 ─────────────────────────────────────────────
|
|
268
|
+
function log(msg) {
|
|
269
|
+
process.stderr.write(`[bridge ${new Date().toISOString()}] ${msg}\n`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sleep(ms, signal) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const t = setTimeout(resolve, ms);
|
|
275
|
+
signal?.addEventListener('abort', () => {
|
|
276
|
+
clearTimeout(t);
|
|
277
|
+
reject(new Error('aborted'));
|
|
278
|
+
}, { once: true });
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── opencode serve 调用 ─────────────────────────────
|
|
283
|
+
async function api(path, options = {}, timeoutMs = 30_000) {
|
|
284
|
+
const ctrl = new AbortController();
|
|
285
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
286
|
+
try {
|
|
287
|
+
const resp = await fetch(`${SERVER_BASE}${path}`, {
|
|
288
|
+
...options,
|
|
289
|
+
signal: ctrl.signal,
|
|
290
|
+
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
|
291
|
+
});
|
|
292
|
+
const text = await resp.text();
|
|
293
|
+
if (!resp.ok) {
|
|
294
|
+
throw new Error(`HTTP ${resp.status}: ${text.slice(0, 300)}`);
|
|
295
|
+
}
|
|
296
|
+
return text ? JSON.parse(text) : null;
|
|
297
|
+
} finally {
|
|
298
|
+
clearTimeout(t);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 获取或创建该微信用户的 opencode session */
|
|
303
|
+
async function getSessionForUser(userId) {
|
|
304
|
+
if (sessionMap[userId]) {
|
|
305
|
+
// 校验 session 仍存在
|
|
306
|
+
try {
|
|
307
|
+
await api(`/session/${sessionMap[userId]}`);
|
|
308
|
+
return sessionMap[userId];
|
|
309
|
+
} catch {
|
|
310
|
+
delete sessionMap[userId];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const session = await api('/session', {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
body: JSON.stringify({ title: `微信: ${userId}` }),
|
|
316
|
+
});
|
|
317
|
+
sessionMap[userId] = session.id;
|
|
318
|
+
saveSessionMap();
|
|
319
|
+
return session.id;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 向 opencode session 发送消息并等待完整回复文本 */
|
|
323
|
+
async function askOpenCode(sessionId, text) {
|
|
324
|
+
const SYSTEM_HINT = `你是微信自动回复助手。收到 [图片: 路径] 或 [文件: 路径] 标记时,可用 read 工具读取内容。
|
|
325
|
+
如需在回复中发送媒体文件(本地绝对路径),在回复文本中用 [media: /绝对/路径] 标记,一行一个,其余内容按普通文本发送。
|
|
326
|
+
定时任务指令(用户要求定时提醒/推送时使用):
|
|
327
|
+
[schedule: 时间表达式 | 任务提示] 添加定时任务。时间表达式支持: HH:MM(每天) / 每N小时 / 每N分钟 / YYYY-MM-DD HH:MM(一次性)
|
|
328
|
+
[schedule_list] 查看当前定时任务
|
|
329
|
+
[schedule_del: 任务ID] 删除指定定时任务
|
|
330
|
+
到点后系统会调用你生成内容并主动推送给用户。`;
|
|
331
|
+
const resp = await api(
|
|
332
|
+
`/session/${sessionId}/message`,
|
|
333
|
+
{
|
|
334
|
+
method: 'POST',
|
|
335
|
+
body: JSON.stringify({ parts: [{ type: 'text', text: `${SYSTEM_HINT}\n\n微信消息:\n${text}` }] }),
|
|
336
|
+
},
|
|
337
|
+
MAX_LLM_RESPONSE_TIME_MS,
|
|
338
|
+
);
|
|
339
|
+
const parts = resp?.parts || [];
|
|
340
|
+
const textParts = parts
|
|
341
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
342
|
+
.map((p) => p.text);
|
|
343
|
+
return textParts.join('\n').trim();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── 微信消息处理 ─────────────────────────────────────
|
|
347
|
+
/** 从消息提取可读文本(媒体下载到本地,供 LLM 用 read 查看) */
|
|
348
|
+
async function extractText(msg) {
|
|
349
|
+
const parts = [];
|
|
350
|
+
for (const item of msg.item_list ?? []) {
|
|
351
|
+
const t = item.type ?? 0;
|
|
352
|
+
if (t === MessageItemType.TEXT) {
|
|
353
|
+
if (item.text_item?.text) {
|
|
354
|
+
if (item.ref_msg) {
|
|
355
|
+
const refTitle = item.ref_msg.title ?? '';
|
|
356
|
+
const refText = item.ref_msg.message_item?.text_item?.text ?? '';
|
|
357
|
+
const refContent = refTitle || refText;
|
|
358
|
+
if (refContent) {
|
|
359
|
+
parts.push(`[引用: ${refContent}]`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
parts.push(item.text_item.text);
|
|
363
|
+
}
|
|
364
|
+
} else if (t === MessageItemType.IMAGE) {
|
|
365
|
+
let desc = '[图片]';
|
|
366
|
+
try {
|
|
367
|
+
const filePath = await downloadMedia({
|
|
368
|
+
encryptQueryParam: item.image_item?.media?.encrypt_query_param,
|
|
369
|
+
aesKeyBase64: item.image_item?.media?.aes_key,
|
|
370
|
+
outDir: MEDIA_DIR,
|
|
371
|
+
});
|
|
372
|
+
if (filePath) desc = `[图片: ${filePath}]`;
|
|
373
|
+
} catch {}
|
|
374
|
+
parts.push(desc);
|
|
375
|
+
} else if (t === MessageItemType.VOICE) {
|
|
376
|
+
parts.push(`[语音] ${item.voice_item?.text ?? ''}`);
|
|
377
|
+
} else if (t === MessageItemType.FILE) {
|
|
378
|
+
let desc = `[文件: ${item.file_item?.file_name ?? 'unknown'}]`;
|
|
379
|
+
try {
|
|
380
|
+
const filePath = await downloadMedia({
|
|
381
|
+
encryptQueryParam: item.file_item?.media?.encrypt_query_param,
|
|
382
|
+
aesKeyBase64: item.file_item?.media?.aes_key,
|
|
383
|
+
fileName: item.file_item?.file_name,
|
|
384
|
+
outDir: MEDIA_DIR,
|
|
385
|
+
});
|
|
386
|
+
if (filePath) desc = `[文件: ${filePath}]`;
|
|
387
|
+
} catch {}
|
|
388
|
+
parts.push(desc);
|
|
389
|
+
} else if (t === MessageItemType.VIDEO) {
|
|
390
|
+
parts.push('[视频]');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return parts.join('\n') || '[空消息]';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 发送媒体标记(LLM 回复形如 [media: /abs/path])并返回剩余文本 */
|
|
397
|
+
async function sendMediaMarks(userId, contextToken, reply) {
|
|
398
|
+
const mediaRegex = /\[media:\s*([^\]]+)\]/g;
|
|
399
|
+
let mediaSent = 0;
|
|
400
|
+
let text = reply;
|
|
401
|
+
let match;
|
|
402
|
+
const files = [];
|
|
403
|
+
while ((match = mediaRegex.exec(reply)) !== null) {
|
|
404
|
+
const p = match[1].trim();
|
|
405
|
+
files.push(p);
|
|
406
|
+
}
|
|
407
|
+
for (const p of files) {
|
|
408
|
+
if (!existsSync(p)) {
|
|
409
|
+
log(`媒体文件不存在,跳过: ${p}`);
|
|
410
|
+
text = text.replace(/\[media:\s*[^\]]+\]/g, '').trim();
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await uploadMedia({
|
|
415
|
+
token: account.token,
|
|
416
|
+
toUser: userId,
|
|
417
|
+
contextToken,
|
|
418
|
+
filePath: p,
|
|
419
|
+
baseUrl: account.baseUrl,
|
|
420
|
+
});
|
|
421
|
+
mediaSent++;
|
|
422
|
+
} catch (err) {
|
|
423
|
+
log(`媒体发送失败 ${p}: ${String(err)}`);
|
|
424
|
+
}
|
|
425
|
+
text = text.replace(/\[media:\s*[^\]]+\]/g, '').trim();
|
|
426
|
+
}
|
|
427
|
+
return { text, mediaSent };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 发送 typing 指示(best-effort) */
|
|
431
|
+
async function sendTypingIndicator(userId, contextToken, status) {
|
|
432
|
+
try {
|
|
433
|
+
let ticket = typingTicketCache.get(userId);
|
|
434
|
+
if (!ticket) {
|
|
435
|
+
const config = await getConfig(account.token, userId, contextToken, account.baseUrl);
|
|
436
|
+
ticket = config.typing_ticket ?? '';
|
|
437
|
+
if (ticket) typingTicketCache.set(userId, ticket);
|
|
438
|
+
}
|
|
439
|
+
if (ticket) {
|
|
440
|
+
await sendTyping(account.token, userId, ticket, status, account.baseUrl);
|
|
441
|
+
}
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function handleMessage(msg) {
|
|
446
|
+
if (msg.message_type !== 1) return; // 仅用户消息
|
|
447
|
+
const fromUser = msg.from_user_id ?? '';
|
|
448
|
+
const contextToken = msg.context_token ?? '';
|
|
449
|
+
const messageId = String(msg.message_id ?? '');
|
|
450
|
+
if (!fromUser || !contextToken) return;
|
|
451
|
+
if (processedIds.has(messageId)) return;
|
|
452
|
+
processedIds.add(messageId);
|
|
453
|
+
if (processedIds.size > 2000) {
|
|
454
|
+
const first = processedIds.values().next().value;
|
|
455
|
+
if (first) processedIds.delete(first);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const text = await extractText(msg);
|
|
459
|
+
if (!text.trim()) return;
|
|
460
|
+
|
|
461
|
+
if (TRIGGER_PREFIX && !text.startsWith(TRIGGER_PREFIX)) return;
|
|
462
|
+
|
|
463
|
+
log(`收到消息 from=${fromUser} len=${text.length}`);
|
|
464
|
+
await sendTypingIndicator(fromUser, contextToken, 1);
|
|
465
|
+
|
|
466
|
+
let sessionId;
|
|
467
|
+
try {
|
|
468
|
+
sessionId = await getSessionForUser(fromUser);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
log(`获取 session 失败: ${String(err)}`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let reply = '';
|
|
475
|
+
try {
|
|
476
|
+
reply = await askOpenCode(sessionId, text);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
log(`调用 opencode 失败: ${String(err)}`);
|
|
479
|
+
reply = `[处理失败: ${String(err.message ?? err)}]`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
await sendTypingIndicator(fromUser, contextToken, 2);
|
|
483
|
+
|
|
484
|
+
if (reply) {
|
|
485
|
+
// 处理定时任务标记(schedule_*)
|
|
486
|
+
let textToSend = processScheduleMarks(reply, fromUser);
|
|
487
|
+
// 再发送媒体标记
|
|
488
|
+
try {
|
|
489
|
+
const { text, mediaSent } = await sendMediaMarks(fromUser, contextToken, textToSend);
|
|
490
|
+
textToSend = text;
|
|
491
|
+
if (mediaSent > 0) log(`已发送 ${mediaSent} 个媒体文件 from=${fromUser}`);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
log(`媒体处理失败: ${String(err)}`);
|
|
494
|
+
}
|
|
495
|
+
if (textToSend) {
|
|
496
|
+
const plainText = stripMarkdown(textToSend);
|
|
497
|
+
const chunks = chunkText(plainText, 3900);
|
|
498
|
+
for (const chunk of chunks) {
|
|
499
|
+
try {
|
|
500
|
+
await sendMessage(account.token, fromUser, chunk, contextToken, account.baseUrl, messageId);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
log(`发送失败: ${String(err)}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
log(`已回复 from=${fromUser} chunks=${chunks.length}`);
|
|
506
|
+
} else {
|
|
507
|
+
log(`已回复(仅媒体) from=${fromUser}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ─── 轮询循环 ─────────────────────────────────────────
|
|
513
|
+
async function pollLoop() {
|
|
514
|
+
let buf = loadSyncBuf();
|
|
515
|
+
let consecutiveFailures = 0;
|
|
516
|
+
let sessionRetries = 0;
|
|
517
|
+
let retryDelay = INITIAL_RETRY_DELAY_MS;
|
|
518
|
+
let nextTimeoutMs;
|
|
519
|
+
while (pollingActive && !pollingAbort?.signal.aborted) {
|
|
520
|
+
try {
|
|
521
|
+
const resp = await getUpdates(account.token, buf, account.baseUrl, nextTimeoutMs);
|
|
522
|
+
if (resp.longpolling_timeout_ms) {
|
|
523
|
+
nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
524
|
+
}
|
|
525
|
+
if ((resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0)) {
|
|
526
|
+
const errcode = resp.errcode ?? resp.ret ?? 0;
|
|
527
|
+
if (errcode === SESSION_EXPIRED_ERRCODE) {
|
|
528
|
+
sessionRetries++;
|
|
529
|
+
log(`Session 过期 (${sessionRetries}/3)`);
|
|
530
|
+
if (sessionRetries >= 3) {
|
|
531
|
+
pollingActive = false;
|
|
532
|
+
log('Session 过期,请重新扫码登录: npx cc-wechat login');
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
consecutiveFailures++;
|
|
539
|
+
log(`API 错误 errcode=${errcode} errmsg=${resp.errmsg ?? ''} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`);
|
|
540
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
541
|
+
await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
|
|
542
|
+
consecutiveFailures = 0;
|
|
543
|
+
} else {
|
|
544
|
+
await sleep(retryDelay, pollingAbort?.signal);
|
|
545
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
consecutiveFailures = 0;
|
|
550
|
+
retryDelay = INITIAL_RETRY_DELAY_MS;
|
|
551
|
+
if (resp.get_updates_buf) {
|
|
552
|
+
buf = resp.get_updates_buf;
|
|
553
|
+
saveSyncBuf(buf);
|
|
554
|
+
}
|
|
555
|
+
for (const msg of resp.msgs ?? []) {
|
|
556
|
+
try {
|
|
557
|
+
await handleMessage(msg);
|
|
558
|
+
} catch (err) {
|
|
559
|
+
log(`处理消息失败: ${String(err)}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (pollingAbort?.signal.aborted) return;
|
|
564
|
+
consecutiveFailures++;
|
|
565
|
+
log(`网络错误: ${String(err)} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`);
|
|
566
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
567
|
+
await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
|
|
568
|
+
consecutiveFailures = 0;
|
|
569
|
+
} else {
|
|
570
|
+
await sleep(retryDelay, pollingAbort?.signal);
|
|
571
|
+
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function startPolling() {
|
|
578
|
+
if (!account || pollingActive) return;
|
|
579
|
+
pollingActive = true;
|
|
580
|
+
pollingAbort = new AbortController();
|
|
581
|
+
pollLoop().catch((err) => {
|
|
582
|
+
if (!pollingAbort?.signal.aborted) {
|
|
583
|
+
log(`Poll loop crashed: ${String(err)}`);
|
|
584
|
+
}
|
|
585
|
+
pollingActive = false;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function stopPolling() {
|
|
590
|
+
pollingActive = false;
|
|
591
|
+
pollingAbort?.abort();
|
|
592
|
+
pollingAbort = null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ─── 主入口 ───────────────────────────────────────────
|
|
596
|
+
export async function startBridge() {
|
|
597
|
+
account = getActiveAccount();
|
|
598
|
+
if (!account) {
|
|
599
|
+
log('未找到账号。请先登录: npx cc-wechat login');
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
log(`账号: ${account.botId} baseUrl: ${account.baseUrl}`);
|
|
603
|
+
log(`opencode server: ${SERVER_BASE}`);
|
|
604
|
+
log(`session 映射: ${Object.keys(sessionMap).length} 个用户`);
|
|
605
|
+
|
|
606
|
+
// 等待 opencode serve 就绪(最多 60s,解决与 serve 同时启动的竞态)
|
|
607
|
+
let serveReady = false;
|
|
608
|
+
for (let i = 0; i < 60; i++) {
|
|
609
|
+
try {
|
|
610
|
+
await api('/global/health', {}, 3000);
|
|
611
|
+
serveReady = true;
|
|
612
|
+
break;
|
|
613
|
+
} catch {}
|
|
614
|
+
await sleep(1000);
|
|
615
|
+
}
|
|
616
|
+
if (serveReady) {
|
|
617
|
+
log('opencode serve 连接正常');
|
|
618
|
+
} else {
|
|
619
|
+
log(`警告: 无法连接 opencode serve (${SERVER_BASE}),收到消息时仍会尝试`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
startPolling();
|
|
623
|
+
|
|
624
|
+
// 启动定时任务调度器(每 20 秒检查一次)
|
|
625
|
+
log(`定时任务: ${tasks.length} 个`);
|
|
626
|
+
schedulerTimer = setInterval(() => {
|
|
627
|
+
runScheduler().catch((err) => log(`调度器错误: ${String(err)}`));
|
|
628
|
+
}, 20_000);
|
|
629
|
+
|
|
630
|
+
const shutdown = () => {
|
|
631
|
+
log('正在停止...');
|
|
632
|
+
stopPolling();
|
|
633
|
+
if (schedulerTimer) clearInterval(schedulerTimer);
|
|
634
|
+
saveSessionMap();
|
|
635
|
+
saveTasks();
|
|
636
|
+
process.exit(0);
|
|
637
|
+
};
|
|
638
|
+
process.on('SIGINT', shutdown);
|
|
639
|
+
process.on('SIGTERM', shutdown);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export {
|
|
643
|
+
parseScheduleExpr,
|
|
644
|
+
addTask,
|
|
645
|
+
deleteTask,
|
|
646
|
+
listTasksFor,
|
|
647
|
+
processScheduleMarks,
|
|
648
|
+
runScheduler,
|
|
649
|
+
log,
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// 兼容直接执行: node bridge.js
|
|
653
|
+
import { fileURLToPath } from 'node:url';
|
|
654
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
655
|
+
startBridge().catch((err) => {
|
|
656
|
+
log(`Fatal: ${String(err)}`);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
});
|
|
659
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* opencode-wechat-bot CLI
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* wechat-bot serve 启动 opencode serve(前台,需配合守护进程/计划任务)
|
|
7
|
+
* wechat-bot bridge 启动桥接进程(前台,需配合守护进程/计划任务)
|
|
8
|
+
* wechat-bot login 调用 cc-wechat 登录
|
|
9
|
+
* wechat-bot status 查看 serve 健康状态
|
|
10
|
+
* wechat-bot install 安装自启服务(macOS: launchd;Windows: 计划任务)
|
|
11
|
+
* wechat-bot uninstall 卸载自启服务
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
SERVER_BASE,
|
|
19
|
+
SERVE_PORT,
|
|
20
|
+
BRIDGE_LOG,
|
|
21
|
+
ensureDirs,
|
|
22
|
+
findCcWechatDir,
|
|
23
|
+
BOT_HOME,
|
|
24
|
+
} from './paths.js';
|
|
25
|
+
|
|
26
|
+
const isWin = process.platform === 'win32';
|
|
27
|
+
|
|
28
|
+
function usage() {
|
|
29
|
+
console.log(`opencode-wechat-bot
|
|
30
|
+
用法:
|
|
31
|
+
wechat-bot serve 启动 opencode serve(前台)
|
|
32
|
+
wechat-bot bridge 启动桥接进程(前台)
|
|
33
|
+
wechat-bot login 调用 cc-wechat 登录
|
|
34
|
+
wechat-bot status 查看 serve 健康状态
|
|
35
|
+
wechat-bot install 安装自启服务(macOS: launchd / Windows: 计划任务)
|
|
36
|
+
wechat-bot uninstall 卸载自启服务
|
|
37
|
+
|
|
38
|
+
环境变量:
|
|
39
|
+
WECHAT_BOT_HOME 数据目录(默认 ~/.wechat-bot)
|
|
40
|
+
WECHAT_STATE_DIR cc-wechat 状态目录(默认 ~/.claude/channels/wechat/<profile>)
|
|
41
|
+
WECHAT_PROFILE cc-wechat 账号 profile(默认 default)
|
|
42
|
+
OPENCODE_SERVER_URL opencode serve 地址(默认 http://127.0.0.1:4100)
|
|
43
|
+
OPENCODE_PORT opencode serve 端口(默认 4100)
|
|
44
|
+
LLM_TIMEOUT_MS LLM 响应超时(默认 300000)
|
|
45
|
+
BOT_PREFIX 可选: 仅响应带此前缀的消息
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function startServe() {
|
|
50
|
+
ensureDirs();
|
|
51
|
+
const opencodeBin = process.env.OPENCODE_BIN || 'opencode';
|
|
52
|
+
const args = ['serve', '--port', String(SERVE_PORT)];
|
|
53
|
+
// 禁用 wechat MCP,避免与 bridge 抢轮询
|
|
54
|
+
const env = {
|
|
55
|
+
...process.env,
|
|
56
|
+
OPENCODE_CONFIG_CONTENT: '{"mcp":{"wechat":{"enabled":false}}}',
|
|
57
|
+
};
|
|
58
|
+
console.log(`[serve] 启动 ${opencodeBin} ${args.join(' ')}`);
|
|
59
|
+
const child = spawn(opencodeBin, args, { env, stdio: 'inherit', shell: false });
|
|
60
|
+
child.on('error', (err) => {
|
|
61
|
+
console.error(`[serve] 启动失败: ${err.message}`);
|
|
62
|
+
console.error(' 请确认已安装 opencode 或在 OPENCODE_BIN 指定可执行文件路径');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
});
|
|
65
|
+
const stop = () => {
|
|
66
|
+
child.kill('SIGTERM');
|
|
67
|
+
};
|
|
68
|
+
process.on('SIGINT', stop);
|
|
69
|
+
process.on('SIGTERM', stop);
|
|
70
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function startBridge() {
|
|
74
|
+
ensureDirs();
|
|
75
|
+
const { startBridge } = await import('./bridge.js');
|
|
76
|
+
await startBridge();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function login() {
|
|
80
|
+
const ccWechat = findCcWechatDir();
|
|
81
|
+
const cli = join(ccWechat, 'dist', 'cli.js');
|
|
82
|
+
const child = spawn(process.execPath, [cli, 'login'], { stdio: 'inherit', shell: false });
|
|
83
|
+
child.on('error', (err) => {
|
|
84
|
+
console.error(`[login] 启动失败: ${err.message}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function status() {
|
|
91
|
+
ensureDirs();
|
|
92
|
+
try {
|
|
93
|
+
const ctrl = new AbortController();
|
|
94
|
+
const t = setTimeout(() => ctrl.abort(), 5000);
|
|
95
|
+
const resp = await fetch(`${SERVER_BASE}/global/health`, { signal: ctrl.signal });
|
|
96
|
+
clearTimeout(t);
|
|
97
|
+
console.log(`serve: ${resp.ok ? 'OK' : `HTTP ${resp.status}`} (${SERVER_BASE})`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.log(`serve: 不可达 (${SERVER_BASE}) — ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
console.log(`数据目录: ${BOT_HOME}`);
|
|
102
|
+
if (existsSync(BRIDGE_LOG)) {
|
|
103
|
+
const tail = readFileSync(BRIDGE_LOG, 'utf-8').trim().split('\n').slice(-5).join('\n');
|
|
104
|
+
console.log(`bridge 日志尾部:\n${tail}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function installService() {
|
|
109
|
+
ensureDirs();
|
|
110
|
+
if (isWin) {
|
|
111
|
+
console.error('Windows 自启请使用计划任务,稍后文档说明。');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
// macOS: 生成 launchd plist 并加载
|
|
115
|
+
const plistDir = join(fileURLToPath(new URL('..', import.meta.url)), 'launchd');
|
|
116
|
+
const labelBase = 'com.opencode-wechat-bot';
|
|
117
|
+
const nodeBin = process.execPath;
|
|
118
|
+
const cliEntry = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
119
|
+
|
|
120
|
+
const makePlist = (name, args, extraEnv) => ({
|
|
121
|
+
Label: `${labelBase}.${name}`,
|
|
122
|
+
ProgramArguments: [nodeBin, cliEntry, ...args],
|
|
123
|
+
EnvironmentVariables: { HOME: process.env.HOME, ...extraEnv },
|
|
124
|
+
WorkingDirectory: BOT_HOME,
|
|
125
|
+
RunAtLoad: true,
|
|
126
|
+
KeepAlive: true,
|
|
127
|
+
StandardOutPath: join(BOT_HOME, 'logs', `${name}.log`),
|
|
128
|
+
StandardErrorPath: join(BOT_HOME, 'logs', `${name}.log`),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const writePlist = (name, obj) => {
|
|
132
|
+
const dict = Object.entries(obj)
|
|
133
|
+
.map(([k, v]) => {
|
|
134
|
+
if (typeof v === 'boolean') return `<key>${k}</key><${v ? 'true' : 'false'}/>`;
|
|
135
|
+
if (typeof v === 'number') return `<key>${k}</key><integer>${v}</integer>`;
|
|
136
|
+
if (Array.isArray(v))
|
|
137
|
+
return `<key>${k}</key><array>${v.map((x) => `<string>${x}</string>`).join('')}</array>`;
|
|
138
|
+
return `<key>${k}</key><string>${v}</string>`;
|
|
139
|
+
})
|
|
140
|
+
.join('\n ');
|
|
141
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n ${dict}\n</dict>\n</plist>\n`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const plists = {
|
|
145
|
+
serve: makePlist(
|
|
146
|
+
'serve',
|
|
147
|
+
['serve'],
|
|
148
|
+
{
|
|
149
|
+
OPENCODE_CONFIG_CONTENT: '{"mcp":{"wechat":{"enabled":false}}}',
|
|
150
|
+
PATH: process.env.PATH || '',
|
|
151
|
+
},
|
|
152
|
+
),
|
|
153
|
+
bridge: makePlist(
|
|
154
|
+
'bridge',
|
|
155
|
+
['bridge'],
|
|
156
|
+
{
|
|
157
|
+
WECHAT_BOT_HOME: BOT_HOME,
|
|
158
|
+
PATH: process.env.PATH || '',
|
|
159
|
+
},
|
|
160
|
+
),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const launchDir = join(process.env.HOME, 'Library', 'LaunchAgents');
|
|
164
|
+
const { mkdirSync } = await import('node:fs');
|
|
165
|
+
mkdirSync(launchDir, { recursive: true });
|
|
166
|
+
for (const [name, obj] of Object.entries(plists)) {
|
|
167
|
+
const plistPath = join(launchDir, `${labelBase}.${name}.plist`);
|
|
168
|
+
const { writeFileSync } = await import('node:fs');
|
|
169
|
+
writeFileSync(plistPath, writePlist(name, obj), 'utf-8');
|
|
170
|
+
console.log(`写入 ${plistPath}`);
|
|
171
|
+
}
|
|
172
|
+
console.log('请手动加载(需要 sudo 时):\n launchctl load ~/Library/LaunchAgents/com.opencode-wechat-bot.serve.plist\n launchctl load ~/Library/LaunchAgents/com.opencode-wechat-bot.bridge.plist');
|
|
173
|
+
console.log(`或使用 launchctl bootstrap gui/$(id -u) ...`);
|
|
174
|
+
if (plistDir) console.log(`模板目录: ${plistDir}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cmd = process.argv[2] || 'bridge';
|
|
178
|
+
|
|
179
|
+
switch (cmd) {
|
|
180
|
+
case 'serve':
|
|
181
|
+
startServe().catch((err) => {
|
|
182
|
+
console.error(err);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
});
|
|
185
|
+
break;
|
|
186
|
+
case 'bridge':
|
|
187
|
+
startBridge().catch((err) => {
|
|
188
|
+
console.error(err);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
case 'login':
|
|
193
|
+
login().catch((err) => {
|
|
194
|
+
console.error(err);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
case 'status':
|
|
199
|
+
status().catch((err) => {
|
|
200
|
+
console.error(err);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
|
203
|
+
break;
|
|
204
|
+
case 'install':
|
|
205
|
+
installService().catch((err) => {
|
|
206
|
+
console.error(err);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
});
|
|
209
|
+
break;
|
|
210
|
+
case 'uninstall':
|
|
211
|
+
console.log('请手动移除 launchd plist 或计划任务。');
|
|
212
|
+
break;
|
|
213
|
+
case 'help':
|
|
214
|
+
case '--help':
|
|
215
|
+
case '-h':
|
|
216
|
+
usage();
|
|
217
|
+
break;
|
|
218
|
+
default:
|
|
219
|
+
usage();
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨平台路径管理。
|
|
3
|
+
*
|
|
4
|
+
* 约定:
|
|
5
|
+
* - WECHAT_BOT_HOME 桥接自身数据目录(sessions/tasks/media/logs/pids),默认 ~/.wechat-bot
|
|
6
|
+
* - WECHAT_STATE_DIR cc-wechat 状态目录(account.json/sync-buf),默认 ~/.claude/channels/wechat/<profile>
|
|
7
|
+
* - WECHAT_PROFILE cc-wechat 账号 profile(默认 'default'),与 cc-wechat store.js 保持一致
|
|
8
|
+
* - OPENCODE_SERVER_URL / OPENCODE_PORT opencode serve 地址(默认 http://127.0.0.1:4100)
|
|
9
|
+
*/
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
const home = homedir();
|
|
18
|
+
const BOT_HOME = process.env.WECHAT_BOT_HOME || join(home, '.wechat-bot');
|
|
19
|
+
const STATE_DIR =
|
|
20
|
+
process.env.WECHAT_STATE_DIR ||
|
|
21
|
+
join(home, '.claude', 'channels', 'wechat', process.env.WECHAT_PROFILE || 'default');
|
|
22
|
+
const MEDIA_DIR = join(BOT_HOME, 'media');
|
|
23
|
+
const SESSION_MAP_FILE = join(BOT_HOME, 'sessions.json');
|
|
24
|
+
const TASKS_FILE = join(BOT_HOME, 'tasks.json');
|
|
25
|
+
const LOG_DIR = join(BOT_HOME, 'logs');
|
|
26
|
+
const SERVE_LOG = join(LOG_DIR, 'serve.log');
|
|
27
|
+
const BRIDGE_LOG = join(LOG_DIR, 'bridge.log');
|
|
28
|
+
const PID_DIR = join(BOT_HOME, 'pids');
|
|
29
|
+
const SERVE_PID_FILE = join(PID_DIR, 'serve.pid');
|
|
30
|
+
const BRIDGE_PID_FILE = join(PID_DIR, 'bridge.pid');
|
|
31
|
+
|
|
32
|
+
const SERVER_BASE = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4100';
|
|
33
|
+
const SERVE_PORT = Number(process.env.OPENCODE_PORT || 4100);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 定位 cc-wechat 包目录。
|
|
37
|
+
* 1. 优先当作已安装依赖解析(require.resolve)
|
|
38
|
+
* 2. 回退扫描 npx 缓存(mac/linux ~/.npm/_npx;win %LOCALAPPDATA%\npm-cache\_npx)
|
|
39
|
+
*/
|
|
40
|
+
function findCcWechatDir() {
|
|
41
|
+
try {
|
|
42
|
+
return dirname(require.resolve('cc-wechat/package.json'));
|
|
43
|
+
} catch {}
|
|
44
|
+
const roots = [join(home, '.npm', '_npx')];
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
roots.push(
|
|
47
|
+
join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'npm-cache', '_npx'),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
try {
|
|
52
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
53
|
+
const candidates = [];
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (!e.isDirectory()) continue;
|
|
56
|
+
const p = join(root, e.name, 'node_modules', 'cc-wechat');
|
|
57
|
+
if (existsSync(join(p, 'package.json'))) candidates.push(p);
|
|
58
|
+
}
|
|
59
|
+
if (candidates.length) return candidates[0];
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
throw new Error(
|
|
63
|
+
'找不到 cc-wechat 包。请安装依赖: npm install cc-wechat 或运行 npx -y -p cc-wechat@latest cc-wechat-server',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureDirs() {
|
|
68
|
+
for (const d of [BOT_HOME, MEDIA_DIR, LOG_DIR, PID_DIR]) {
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(d, { recursive: true });
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
home,
|
|
77
|
+
BOT_HOME,
|
|
78
|
+
STATE_DIR,
|
|
79
|
+
MEDIA_DIR,
|
|
80
|
+
SESSION_MAP_FILE,
|
|
81
|
+
TASKS_FILE,
|
|
82
|
+
LOG_DIR,
|
|
83
|
+
SERVE_LOG,
|
|
84
|
+
BRIDGE_LOG,
|
|
85
|
+
PID_DIR,
|
|
86
|
+
SERVE_PID_FILE,
|
|
87
|
+
BRIDGE_PID_FILE,
|
|
88
|
+
SERVER_BASE,
|
|
89
|
+
SERVE_PORT,
|
|
90
|
+
findCcWechatDir,
|
|
91
|
+
ensureDirs,
|
|
92
|
+
};
|