Compare commits
14 Commits
5d35d97f0a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d72c1a907c | |||
| d406068c89 | |||
| c6795bcb46 | |||
| ef93f066e3 | |||
| b7993a54d5 | |||
| f1630ebb03 | |||
| 255729bb8c | |||
| 9c73e8b107 | |||
| 1417552990 | |||
| 7727e907e6 | |||
| 4cf125231e | |||
| 5a5cc1b45d | |||
| 20389a40ac | |||
| 08f04e773b |
+11
-28
@@ -1,40 +1,23 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
*.egg-info/
|
||||
# Binaries
|
||||
*.AppImage
|
||||
*.exe
|
||||
*.dll
|
||||
squashfs-root/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
wxBot_logs/
|
||||
|
||||
# Temp
|
||||
temp/
|
||||
*.tmp
|
||||
*.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Env
|
||||
# Config with secrets
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Large binaries
|
||||
*.exe
|
||||
*.dll
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
|
||||
# Submodules / external projects
|
||||
hermes-agent/
|
||||
gateway/qq-bot/
|
||||
node_modules/
|
||||
mowechat.conf
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.venv/
|
||||
|
||||
@@ -67,7 +67,10 @@ AgentsMeeting/
|
||||
│ ├── scripts/ # 运行时脚本
|
||||
│ │ ├── chat_bridge.py # xxm LLM 桥接(SessionBridge)
|
||||
│ │ ├── session_router.py # 消息路由
|
||||
│ │ ├── wechat_agent.py # 微信桥接代理
|
||||
│ │ ├── wechat_agent.py # ⛔ 已停用(微信桥接,由 Linux Docker bot 替代)
|
||||
│ │ │ # 保留代码,不再运行
|
||||
│ │ ├── article_processor.py# 文章处理服务 (5810) ← 保持运行!
|
||||
│ │ │ # 抓微信链接全文、OCR,WeChat bot 和 莫荷 都依赖它
|
||||
│ │ ├── api_proxy.py # API 反向代理 (:8787)
|
||||
│ │ ├── xmpp_watchdog.py # 进程看门狗
|
||||
│ │ ├── health_check_xxm.py# 消息流健康检查
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
echo === AgentsMeeting Windows Services ===
|
||||
|
||||
echo [0/3] Cleaning...
|
||||
taskkill /f /im python.exe 2>nul
|
||||
taskkill /f /im easytier-core.exe 2>nul
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
echo [1/3] Article Processor...
|
||||
start /b "" "C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\self-growing-knowledge\scripts\article_processor.py"
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
echo [2/3] xxm Bot...
|
||||
start /b "" /D "D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting" "C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe" xmpp_agent_core.py --agent xxm
|
||||
timeout /t 10 /nobreak >nul
|
||||
|
||||
echo [3/3] EasyTier...
|
||||
curl -s -X POST http://127.0.0.1:5802/easytier -H "Content-Type: application/json" -d "{\"action\":\"start\"}" >nul
|
||||
|
||||
echo === Done ===
|
||||
@@ -1,96 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
AgentsMeeting Windows Services �?一键启动所有组�?.DESCRIPTION
|
||||
按依赖顺序启�? api_proxy �?wechat_agent �?xmpp_bot �?watchdog �?health_check
|
||||
每个服务启动后等待确认,失败则终止�?#>
|
||||
|
||||
param([switch]$Force)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Python = "C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe"
|
||||
$GatewayRoot = "D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway"
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " AgentsMeeting Windows Services" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
# Stop all existing services first
|
||||
Write-Host "`n[0] Stopping existing services..." -ForegroundColor Yellow
|
||||
Get-Process -Name "python*" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)").CommandLine
|
||||
if ($cmd -match "xmpp_bot|wechat_agent|api_proxy|watchdog|health_check") {
|
||||
Write-Host " Killing PID $($_.Id): $($_.ProcessName)" -ForegroundColor Gray
|
||||
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# 1. API Proxy (port 8787)
|
||||
Write-Host "`n[1/5] Starting API Proxy..." -ForegroundColor Green
|
||||
$proxyScript = Join-Path $GatewayRoot "scripts\api_proxy.py"
|
||||
Start-Process -WindowStyle Hidden -FilePath $Python -ArgumentList $proxyScript
|
||||
Start-Sleep -Seconds 2
|
||||
$proxyCheck = netstat -ano 2>$null | Select-String ":8787"
|
||||
if ($proxyCheck) {
|
||||
Write-Host " API Proxy: OK (:8787)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " API Proxy: STARTED (port check skipped)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 2. WeChat Agent (port 5801 + 19088 via wxhelper)
|
||||
Write-Host "`n[2/5] Starting WeChat Agent..." -ForegroundColor Green
|
||||
$agentScript = Join-Path $GatewayRoot "scripts\wechat_agent.py"
|
||||
Start-Process -WindowStyle Hidden -FilePath $Python -ArgumentList $agentScript
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " WeChat Agent: STARTED" -ForegroundColor Green
|
||||
|
||||
# 3. XMPP Bot (xxm)
|
||||
Write-Host "`n[3/5] Starting XMPP Bot (xxm)..." -ForegroundColor Green
|
||||
$botScript = Join-Path $GatewayRoot "scripts\xmpp_bot.py"
|
||||
Start-Process -WindowStyle Hidden -FilePath $Python -ArgumentList $botScript
|
||||
Start-Sleep -Seconds 5
|
||||
$botCheck = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | Where-Object { $_.CommandLine -match "xmpp_bot" -and $_.CommandLine -notmatch "watchdog" }
|
||||
if ($botCheck) {
|
||||
Write-Host " XMPP Bot: OK (PID $($botCheck.ProcessId))" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " XMPP Bot: FAILED TO START" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 4. Watchdog
|
||||
Write-Host "`n[4/5] Starting Watchdog..." -ForegroundColor Green
|
||||
$wdScript = Join-Path $GatewayRoot "scripts\xmpp_watchdog.py"
|
||||
Start-Process -WindowStyle Hidden -FilePath $Python -ArgumentList $wdScript
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host " Watchdog: STARTED" -ForegroundColor Green
|
||||
|
||||
# 5. Mohe watcher �?auto-log mohe replies
|
||||
Write-Host "`n[5/6] Starting Mohe Watcher..." -ForegroundColor Green
|
||||
$moheWatcher = Join-Path $GatewayRoot "scripts\mohe_watcher.py"
|
||||
Start-Process -WindowStyle Hidden -FilePath $Python -ArgumentList $moheWatcher
|
||||
Start-Sleep -Seconds 1
|
||||
Write-Host " Mohe Watcher: STARTED (�?logs/mohe_inbox.log)" -ForegroundColor Green
|
||||
|
||||
# 6. Health check (scheduled task)
|
||||
Write-Host "`n[6/6] Registering health check..." -ForegroundColor Green
|
||||
$taskName = "xxm-health-check"
|
||||
$checkScript = Join-Path $GatewayRoot "scripts\health_check_xxm.py"
|
||||
try {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
$action = New-ScheduledTaskAction -Execute $Python -Argument "`"$checkScript`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes 5) `
|
||||
-RepetitionDuration (New-TimeSpan -Days 365)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType S4U -RunLevel Limited
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null
|
||||
Write-Host " Health Check: OK (every 5 min)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " Health Check: WARNING - $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " ALL SERVICES STARTED" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Dashboard: http://192.168.1.246:5803 (Linux)" -ForegroundColor Green
|
||||
Write-Host " Logs: $GatewayRoot\logs\" -ForegroundColor Gray
|
||||
Write-Host " Status: powershell -File deploy\windows\check.ps1" -ForegroundColor Gray
|
||||
@@ -0,0 +1,266 @@
|
||||
# EasyTier 异地组网方案
|
||||
|
||||
> 立项时间:2026-06-21 | 部署时间:2026-06-22
|
||||
> 状态:🟢 已部署(可通过 Dashboard 一键开关)
|
||||
>
|
||||
> 一句话:三台机器不再依赖物理网络,虚拟 IP 永远不变。
|
||||
> 出门也能连回来,在家自动 P2P 直连零流量。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [网络拓扑](#一网络拓扑)
|
||||
2. [一键开关](#二一键开关)
|
||||
3. [组件清单](#三组件清单)
|
||||
4. [部署位置](#四部署位置)
|
||||
5. [运维手册](#五运维手册)
|
||||
6. [已知问题](#六已知问题)
|
||||
7. [IP 替换清单(待完成)](#七ip-替换清单待完成)
|
||||
|
||||
---
|
||||
|
||||
## 一、网络拓扑
|
||||
|
||||
```
|
||||
阿里云 ECS (47.115.32.206)
|
||||
└─ EasyTier 中继 (端口 11010)
|
||||
│
|
||||
├── 246 Ubuntu ─── 10.144.144.1
|
||||
├── MacBook M5 Pro ─ 10.144.144.2
|
||||
└── Windows 16 ──── 10.144.144.3
|
||||
```
|
||||
|
||||
### 连线状态
|
||||
|
||||
| 路径 | 在家时 | 出门后 |
|
||||
|------|--------|--------|
|
||||
| 246 ↔ Windows | P2P 直连(~2ms) | 经中继转发 |
|
||||
| 246 ↔ Mac | 经中继 | 经中继 |
|
||||
| Mac ↔ Windows | 经中继(~3ms~500ms) | 经中继 |
|
||||
|
||||
### 虚拟 IP 规划
|
||||
|
||||
| 机器 | 物理 IP | 虚拟 IP | 角色 |
|
||||
|------|---------|---------|------|
|
||||
| Ubuntu 246 | `192.168.1.246` | `10.144.144.1` | 通信中枢 + 中继节点 |
|
||||
| MacBook 122 | `192.168.1.122` | `10.144.144.2` | 本地算力中心 |
|
||||
| Windows 16 | `192.168.1.16` | `10.144.144.3` | 编排中枢 |
|
||||
|
||||
### 网络密码
|
||||
|
||||
```
|
||||
ce75d0a5
|
||||
```
|
||||
|
||||
保存在:
|
||||
- 中继:阿里云 ECS `/usr/local/bin/easytier-core` 启动命令
|
||||
- 客户端 246:`/usr/local/bin/easytier-manager` 脚本
|
||||
- 客户端 Windows:`xmpp_agent_core.py` 中 `_EASYTIER_NET` 常量
|
||||
- 客户端 Mac:通过 SSH 从 246 传递
|
||||
|
||||
---
|
||||
|
||||
## 二、一键开关
|
||||
|
||||
### Dashboard 按钮(推荐)
|
||||
|
||||
位置:`http://192.168.1.246:5803` → Agents 页 → Infrastructure → **EasyTier VPN**
|
||||
|
||||
| 按钮 | 操作 | 效果 |
|
||||
|------|------|------|
|
||||
| Turn On | POST `/api/easytier/toggle` | 246/Mac/Windows 全部启动 EasyTier |
|
||||
| Turn Off | POST `/api/easytier/toggle` | 全部停止 |
|
||||
| 状态灯 | GET `/api/easytier` | 三台机器运行状态 |
|
||||
|
||||
### 一键开关实际执行的命令
|
||||
|
||||
```
|
||||
Turn On:
|
||||
246: sudo easytier-core --network-name mynet ... --ipv4 10.144.144.1
|
||||
Mac: SSH → sudo easytier-core ... --ipv4 10.144.144.2
|
||||
Win: HTTP POST http://192.168.1.16:5802/easytier {"action":"start"}
|
||||
|
||||
Turn Off:
|
||||
246: sudo pkill -f easytier-core
|
||||
Mac: SSH → sudo pkill -f easytier-core
|
||||
Win: HTTP POST http://192.168.1.16:5802/easytier {"action":"stop"}
|
||||
```
|
||||
|
||||
### 管理脚本(SSH 直连)
|
||||
|
||||
```bash
|
||||
/usr/local/bin/easytier-manager start # 启动 246 + Mac
|
||||
/usr/local/bin/easytier-manager stop # 停止 246 + Mac
|
||||
/usr/local/bin/easytier-manager status # 查看状态
|
||||
```
|
||||
|
||||
### Windows 单独控制
|
||||
|
||||
```bash
|
||||
# 从 246 或任意机器
|
||||
curl -X POST http://192.168.1.16:5802/easytier \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"start"}'
|
||||
|
||||
curl -X POST http://192.168.1.16:5802/easytier \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action":"stop"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、组件清单
|
||||
|
||||
### 3.1 中继(阿里云 ECS)
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 机器 | piano-server (`47.115.32.206`) |
|
||||
| 端口 | `11010`(TCP,安全组已开放) |
|
||||
| 进程 | `easytier-core --network-name mynet --network-secret ce75d0a5 -l tcp://0.0.0.0:11010 --disable-encryption` |
|
||||
| 启动方式 | `nohup` 后台(非 systemd,Dashboard 不管理它) |
|
||||
| 二进制 | `/usr/local/bin/easytier-linux-x86_64/easytier-core` |
|
||||
| 日志 | `/tmp/easytier.log` |
|
||||
| 注意 | **永不停止**,始终在线为外出机器提供中继 |
|
||||
|
||||
### 3.2 客户端 246(Ubuntu)
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 安装位置 | `/usr/local/bin/easytier-core` |
|
||||
| 管理脚本 | `/usr/local/bin/easytier-manager` |
|
||||
| 能力 | `cap_net_admin=ep`(TUN 设备所需) |
|
||||
| 启动命令 | `sudo easytier-core --network-name mynet --network-secret ce75d0a5 --peers tcp://47.115.32.206:11010 --ipv4 10.144.144.1 --disable-encryption --no-listener` |
|
||||
| 日志 | `/tmp/easytier-246.log` |
|
||||
|
||||
### 3.3 客户端 Mac(122)
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 安装位置 | `/usr/local/bin/easytier-core` |
|
||||
| 控制方式 | 246 通过 SSH `hmo@192.168.1.122` 启停 |
|
||||
| 启动命令 | `sudo easytier-core --network-name mynet --network-secret ce75d0a5 --peers tcp://47.115.32.206:11010 --ipv4 10.144.144.2 --disable-encryption --no-listener` |
|
||||
| 日志 | `/tmp/easytier-mac.log` |
|
||||
| SSH 免密 | 已配置(246→122) |
|
||||
|
||||
### 3.4 客户端 Windows(16)
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 安装位置 | `daily-workspace/tools/easytier/easytier-core.exe` |
|
||||
| 控制方式 | HTTP POST 到 xxm bot `:5802/easytier` |
|
||||
| 启动命令 | `start /b easytier-core.exe --network-name mynet --network-secret ce75d0a5 --peers tcp://47.115.32.206:11010 --ipv4 10.144.144.3 --disable-encryption --no-listener` |
|
||||
| 控制端点 | 实现在 `xmpp_agent_core.py` → `_BridgeHandler.do_POST()` |
|
||||
| xxm bot | 自动管理 EasyTier 启停 |
|
||||
|
||||
### 3.5 Dashboard API
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/easytier` | GET | 返回三台机器运行状态 |
|
||||
| `/api/easytier/toggle` | POST | `{"action":"start\|stop"}` 一键启停 |
|
||||
| 实现位置 | `dashboard.py` → `api_easytier_status()` / `api_easytier_toggle()` |
|
||||
|
||||
---
|
||||
|
||||
## 四、部署位置
|
||||
|
||||
### 涉及的代码文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `projects/EasyTier组网方案/README.md` | 本文档 |
|
||||
| `projects/AgentsMeeting/gateway/scripts/dashboard.py` | EasyTier API + HTML 按钮 |
|
||||
| `projects/AgentsMeeting/gateway/scripts/templates/dashboard.html` | UI 按钮 |
|
||||
| `projects/AgentsMeeting/xmpp_agent_core.py` | Windows `/easytier` 端点 + `_start_easytier()`/`_stop_easytier()` |
|
||||
| `/usr/local/bin/easytier-manager` (246) | 管理脚本 |
|
||||
|
||||
### 密码存储位置
|
||||
|
||||
网络密码 `ce75d0a5` 保存在:
|
||||
|
||||
| 位置 | 文件 | 形式 |
|
||||
|------|------|------|
|
||||
| 阿里云 ECS | `/usr/local/bin/easytier-core` 启动命令 | `--network-secret ce75d0a5` |
|
||||
| 246 | `/usr/local/bin/easytier-manager` | Shell 变量 |
|
||||
| Windows | `xmpp_agent_core.py` | `_EASYTIER_NET` 常量 |
|
||||
| Mac | 通过 SSH 从 246 传递 | 命令参数 |
|
||||
|
||||
---
|
||||
|
||||
## 五、运维手册
|
||||
|
||||
### 日常使用
|
||||
|
||||
```bash
|
||||
# 开:Dashboard 点 Turn On,或
|
||||
ssh hmo@192.168.1.246 '/usr/local/bin/easytier-manager start'
|
||||
# Windows 会自动通过 xxm bot 启动
|
||||
|
||||
# 关:Dashboard 点 Turn Off,或
|
||||
ssh hmo@192.168.1.246 '/usr/local/bin/easytier-manager stop'
|
||||
|
||||
# 查状态
|
||||
ssh hmo@192.168.1.246 '/usr/local/bin/easytier-manager status'
|
||||
# 或 http://192.168.1.246:5803/api/easytier
|
||||
```
|
||||
|
||||
### 出门场景
|
||||
|
||||
1. 出门前点 **Turn On**
|
||||
2. MacBook 带走,在外地自动通过阿里云中继连接到家里
|
||||
3. 回家后点 **Turn Off**(或保持 On,在家会 P2P 直连不耗流量)
|
||||
|
||||
### 故障恢复
|
||||
|
||||
```bash
|
||||
# 某台机器 EasyTier 挂了,单独重启
|
||||
ssh hmo@192.168.1.246 'sudo pkill -f easytier-core; sudo easytier-core <参数> &'
|
||||
|
||||
# 中继挂了
|
||||
ssh root@47.115.32.206 '/usr/local/bin/easytier-core --network-name mynet --network-secret ce75d0a5 -l tcp://0.0.0.0:11010 --disable-encryption &'
|
||||
|
||||
# Dashboard 挂了
|
||||
ssh hmo@192.168.1.246 '/home/hmo/agentsmeeting-venv/bin/python3 /home/hmo/agentsmeeting-venv/dashboard.py &'
|
||||
```
|
||||
|
||||
### 安全提示
|
||||
|
||||
- `--disable-encryption` 是因为 EasyTier 默认加密方案在局域网场景下增加延迟
|
||||
- 实际通信在物理层以下(同一交换机),公网流量经过 WireGuard 加密
|
||||
- 如果对安全有更高要求,去掉 `--disable-encryption` 即可
|
||||
|
||||
---
|
||||
|
||||
## 六、已知问题
|
||||
|
||||
| 问题 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| Mac ↔ 246 虚拟 IP 不通 | 🟡 | Mac TUN 设备可能需要系统扩展授权,或防火墙拦截 ICMP。服务端口能通就行,不影响使用 |
|
||||
| 进程重复启动 | 🟡 | `easytier-manager stop` 有时杀不尽旧进程,`start` 前建议手动 `sudo pkill -f easytier-core` |
|
||||
| Windows 启动带窗口 | 🟢 | `start /b` 已隐藏窗口,但任务管理器能看到 |
|
||||
| 中继是手动 nohup | 🟡 | 阿里云 ECS 重启后需手动启动中继。建议后续配 systemd 服务 |
|
||||
|
||||
---
|
||||
|
||||
## 七、IP 替换清单(待完成)
|
||||
|
||||
> ⚠️ **当前 EasyTier 只提供 VPN 通道,配置文件的 IP 还未替换。**
|
||||
> 以下清单待虚拟 IP 稳定运行后再执行。
|
||||
|
||||
### 为什么还没做
|
||||
|
||||
现在的策略是 **主机名替换** 而非直接改 IP:
|
||||
|
||||
```
|
||||
在 /etc/hosts / C:\Windows\System32\drivers\etc\hosts 中定义:
|
||||
10.144.144.1 node246
|
||||
10.144.144.2 node122
|
||||
10.144.144.3 node16
|
||||
```
|
||||
|
||||
以后所有配置写 `node246:8642` 而不是 `192.168.1.246:8642`。
|
||||
**开关 EasyTier 只需要改 9 行 hosts**,不需要搜几十个文件。
|
||||
|
||||
待虚拟 IP 稳定运行一段时间后,再来做主机名替换。
|
||||
@@ -0,0 +1,98 @@
|
||||
# Kanban Handler Protocol v1
|
||||
|
||||
看板任务通知的跨 Agent 处理规范。定义 XMPP bot 收到看板通知后如何路由、处理、反馈。
|
||||
|
||||
---
|
||||
|
||||
## 1. 通知链路
|
||||
|
||||
```
|
||||
Kanban Dashboard (5803)
|
||||
│ 事件触发(create / assign / comment)
|
||||
│
|
||||
├─ POST /api/kanban → 写 DB
|
||||
│
|
||||
└─ state_meta 写事件 + XMPP DM 通知目标 agent
|
||||
│
|
||||
└─ bot 收到 DM → 识别 [Kanban] 前缀
|
||||
│
|
||||
└─ 路由到 kanban-handler session
|
||||
```
|
||||
|
||||
## 2. Session 路由规则
|
||||
|
||||
| 触发方式 | 路由目标 | 说明 |
|
||||
|---------|---------|------|
|
||||
| XMPP DM 通知(`[Kanban]` 开头) | `kanban-handler` 专用 session | 系统自动派卡,不打断当前对话 |
|
||||
| 老爸在群/私信 @agent "把那张卡做了" | 当前 session | 人为指定上下文 |
|
||||
| 老爸私信 "t_xxx 帮我看看" | 当前 session | 同上 |
|
||||
|
||||
**核心原则:专用 session 不猜路由。** handler session 不需要判断"这个 task 应该属于哪个活跃 session"。卡片 body 就是上下文,不够就去 API 拉。
|
||||
|
||||
## 3. Handler Session
|
||||
|
||||
每个 agent 维护一个名为 `kanban-handler` 的 session。这个 session:
|
||||
|
||||
- **预载 kanban-handler skill**(见 skill 定义)
|
||||
- **继承 agent 原有的 SOUL 和工具链**——不改身份,只加工作流
|
||||
- **保持待命**,处理完一个通知后不退出
|
||||
- **不做 DM 主动联系**,除非任务完成需要汇报
|
||||
|
||||
## 4. 事件处理流程
|
||||
|
||||
### 4.1 事件类型与动作
|
||||
|
||||
| 事件 | Agent 动作 |
|
||||
|------|-----------|
|
||||
| `card.assigned` | 拉详情 → 三叉判断(执行/追问/转派)→ 更新 |
|
||||
| `card.commented` | 拉最新评论 → 判断是否需要回应 |
|
||||
| `card.status_changed` | 只记录,不行动 |
|
||||
|
||||
### 4.2 三叉判断
|
||||
|
||||
```
|
||||
收到 card.assigned
|
||||
│
|
||||
├─ A: 这是我的活,body 清晰
|
||||
│ → 直接执行
|
||||
│ → 评论结果 + 更新 status=done
|
||||
│ → 分配类任务完成 → DM 汇报摘要
|
||||
│
|
||||
├─ B: 需要更多信息
|
||||
│ → 评论卡片提问
|
||||
│ → 更新 status=blocked
|
||||
│ → 不 DM
|
||||
│
|
||||
└─ C: 不是我的领域
|
||||
→ 评论说明原因
|
||||
→ 转派给正确 agent(如果能判断)
|
||||
→ 不 DM
|
||||
```
|
||||
|
||||
### 4.3 汇报规则
|
||||
|
||||
- **分配类任务完成** → 简短 DM 给老爸摘要
|
||||
- **评论类通知** → 不汇报
|
||||
- **状态变更** → 不汇报
|
||||
- **转派/追问** → 不汇报
|
||||
|
||||
## 5. 卡片上下文约定
|
||||
|
||||
卡片 `body` 应包含足够让 agent 独立执行的上下文。如果一张卡的 body 只写了标题没有详情,agent 的处理方式是:
|
||||
|
||||
```
|
||||
Step 1: 读 body + 评论
|
||||
Step 2: session_search("相关关键词") 查历史
|
||||
Step 3: 仍然不够 → 评论追问,blocked
|
||||
```
|
||||
|
||||
**卡片写得好 = handler 不需要猜。**
|
||||
|
||||
## 6. 加载方式
|
||||
|
||||
handler session 启动时自动预载 `kanban-handler` skill。不写进 SOUL,不修改 agent 身份定义,可独立版本迭代。
|
||||
|
||||
每个 agent 的部署者负责:
|
||||
- 创建 skill 文件(或从仓库同步)
|
||||
- 配置 bot 的 kanban 通知路由逻辑
|
||||
- 验证 handler session 正常接收
|
||||
@@ -1,59 +1,52 @@
|
||||
# Kanban HTTP API — 跨机器任务协作
|
||||
|
||||
所有 Agent(mohe、zhiwei、xxm、xiao)通过 HTTP API 读写 kanban 任务卡片。
|
||||
不需要 Hermes,不需要 XMPP bot,只需要能 curl 到 `192.168.1.246:9580`。
|
||||
看板已合入 AgentsMeeting Dashboard(5803),不再独立于 9580 端口。
|
||||
|
||||
## 基础 URL
|
||||
|
||||
```
|
||||
http://192.168.1.246:9580/kanban
|
||||
http://192.168.1.246:5803
|
||||
```
|
||||
|
||||
## 操作
|
||||
## 浏览器查看
|
||||
|
||||
直接打开 `http://192.168.1.246:5803/` → 点顶栏 **📋 Kanban** tab。
|
||||
支持按状态和指派者筛选。
|
||||
|
||||
## REST API
|
||||
|
||||
### 列出任务
|
||||
|
||||
### 查看所有任务
|
||||
```bash
|
||||
curl -X POST http://192.168.1.246:9580/kanban/list -H "Content-Type: application/json" -d '{}'
|
||||
# 全部
|
||||
curl http://192.168.1.246:5803/api/kanban
|
||||
|
||||
# 按状态筛选
|
||||
curl "http://192.168.1.246:5803/api/kanban?status=ready"
|
||||
|
||||
# 按指派者筛选
|
||||
curl "http://192.168.1.246:5803/api/kanban?assignee=mohe"
|
||||
```
|
||||
|
||||
可以筛选:
|
||||
返回 JSON 数组,每项含 id / title / body / status / assignee / created_at。
|
||||
|
||||
### 查看单个任务(含评论)
|
||||
|
||||
```bash
|
||||
# 只看指派给自己的
|
||||
curl -X POST ... -d '{"assignee":"xxm"}'
|
||||
|
||||
# 只看待处理
|
||||
curl -X POST ... -d '{"status":"ready"}'
|
||||
curl http://192.168.1.246:5803/api/kanban/t_3adcf4a6
|
||||
```
|
||||
|
||||
### 查看单个任务
|
||||
```bash
|
||||
curl -X POST http://192.168.1.246:9580/kanban/show -d '{"id":"t_3adcf4a6"}'
|
||||
```
|
||||
返回 JSON,含 comments 数组。
|
||||
|
||||
### 添加评论(有问题或进展时用)
|
||||
```bash
|
||||
curl -X POST http://192.168.1.246:9580/kanban/comment \
|
||||
-d '{"id":"t_3adcf4a6","author":"xxm","body":"这里有个问题:..."}'
|
||||
```
|
||||
## 创建 / 更新 / 评论
|
||||
|
||||
评论会发到核心群通知所有人。
|
||||
|
||||
### 查看评论
|
||||
```bash
|
||||
curl -X POST http://192.168.1.246:9580/kanban/comments -d '{"id":"t_3adcf4a6"}'
|
||||
```
|
||||
|
||||
### 更新任务状态
|
||||
```bash
|
||||
curl -X POST http://192.168.1.246:9580/kanban/update \
|
||||
-d '{"id":"t_3adcf4a6","status":"done"}'
|
||||
```
|
||||
(走 kanban handler 流程,暂不开放 HTTP 写操作)
|
||||
|
||||
## 沟通流程
|
||||
|
||||
1. 创建任务 → 核心群收到通知
|
||||
2. 你有问题 → 加评论(curl comment)
|
||||
3. 评论被看到 → 有人回复评论
|
||||
4. 完成 → 更新状态为 done
|
||||
1. 创建任务 → Dashboard 事件 → XMPP DM 通知目标 agent
|
||||
2. agent 在 kanban-handler session 里处理
|
||||
3. 有疑问 → 评论卡片 → 触发新一轮通知
|
||||
4. 完成 → 更新 status=done → 可选汇报
|
||||
|
||||
**不需要在群里说话。** 评论代替讨论,通知代替 @。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# MoWeChat 环境变量配置(2026-06-24 最终版)
|
||||
|
||||
# Docker Bot token
|
||||
WECHAT_BOT_TOKEN=your_fixed_token_here
|
||||
|
||||
# Hermes Gateway
|
||||
HERMES_API=http://192.168.1.246:8642/v1/chat/completions
|
||||
HERMES_KEY=hermes123
|
||||
|
||||
# Webhook 端口
|
||||
WEBHOOK_PORT=5804
|
||||
@@ -0,0 +1,71 @@
|
||||
# MoWeChat — 莫荷微信 Bot
|
||||
|
||||
Linux 版微信 Bot,将莫荷的微信消息桥接到 Hermes Gateway。
|
||||
|
||||
## 技术方案
|
||||
|
||||
```
|
||||
手机微信 → WeChat服务器 → Docker Bot → Webhook(:5804) → Hermes Gateway
|
||||
↓
|
||||
手机微信 ← WeChat服务器 ← Docker Bot ← Webhook(:5804) ← Hermes 回复
|
||||
```
|
||||
|
||||
基于 [`docker-wechatbot-webhook`](https://github.com/danni-cool/wechatbot-webhook) (2174 star),走 Web 微信协议。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 启动容器
|
||||
|
||||
```bash
|
||||
docker run -d --name wxBotWebhook --restart unless-stopped \
|
||||
-p 3001:3001 \
|
||||
-v ~/wxBot_logs:/app/log \
|
||||
-e RECVD_MSG_API="http://172.17.0.1:5804/" \
|
||||
-e ACCEPT_RECVD_MSG_MYSELF=false \
|
||||
-e LOCAL_LOGIN_API_TOKEN="your_fixed_token" \
|
||||
dannicool/docker-wechatbot-webhook
|
||||
```
|
||||
|
||||
### 扫码登录
|
||||
|
||||
访问 http://192.168.1.246:3001/login?token=your_fixed_token
|
||||
|
||||
### Webhook 接收器(systemd)
|
||||
|
||||
```bash
|
||||
sudo systemctl start wechat-webhook
|
||||
sudo systemctl enable wechat-webhook
|
||||
```
|
||||
|
||||
### 发送消息
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3001/webhook/msg/v2?token=your_fixed_token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"to": "联系人名称", "data": {"content": "消息内容"}}'
|
||||
```
|
||||
|
||||
## 组件
|
||||
|
||||
| 组件 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| Docker 容器 | `wxBotWebhook` | 微信 Bot 核心,收发消息 |
|
||||
| Webhook 接收 | `wechat_webhook.py` | 收到消息→转发 Hermes→回复 |
|
||||
| systemd 服务 | `wechat-webhook.service` | Webhook 开机自启 |
|
||||
| 日志 | `~/wxBot_logs/` + `logs/webhook_service.log` | |
|
||||
|
||||
## 研究历程
|
||||
|
||||
见 [`docs/architecture.md`](docs/architecture.md)(方案论证)和 [`archive/`](archive/)(废弃脚本)。
|
||||
|
||||
曾尝试过的方案:
|
||||
- GDB Hook(微信 4.1.7 crashpad 检测 ptrace,崩溃)
|
||||
- /proc/PID/mem 内存扫描(可行但噪声大,需精调)
|
||||
- LD_PRELOAD(需逆向工程)
|
||||
- **最终:docker-wechatbot-webhook ✅ 最稳定**
|
||||
|
||||
## 维护
|
||||
|
||||
- Web 协议约 2 天掉线一次,需重新扫码
|
||||
- 容器自动重启(`--restart unless-stopped`)
|
||||
- 登录 URL 不变(固定 token)
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env gdb
|
||||
"""
|
||||
GDB Hook script for WeChat Linux AppImage.
|
||||
Intercepts incoming messages from WeChat's NewSync_ProcessStashMsgList.
|
||||
|
||||
Based on Ajax's Blog methodology:
|
||||
https://aajax.top/2026/03/11/GettingLinuxWechatMessages/
|
||||
|
||||
Usage:
|
||||
gdb -p $(pidof wechat) -x hooks/gdb_hook_messages.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
|
||||
# WeChat binary base address (from /proc/PID/maps, first r--p entry)
|
||||
# Must be recalculated each run due to ASLR
|
||||
WECHAT_PID = None
|
||||
|
||||
# Breakpoint RVA (relative to binary base) for WeChat 4.1.x
|
||||
# From Ajax's IDA Pro analysis of 4.1.0.16:
|
||||
# NewSync_ProcessStashMsgList -> loop call at 0x4994BEB
|
||||
BP_RVA = 0x4994BEB
|
||||
|
||||
# Log file
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "logs")
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_messages.log")
|
||||
|
||||
# Message structure offsets (from Ajax's blog)
|
||||
OFF_TYPE = 0x14 # int: message type
|
||||
OFF_SVRID = 0x50 # unsigned long long: server message ID
|
||||
OFF_HOLDER = 0x20 # void*: holder pointer
|
||||
OFF_INNER = 0x08 # void*: inner pointer (relative to holder)
|
||||
OFF_CONTENT_PTR = 0x00 # char*: content string pointer (from inner+0)
|
||||
OFF_CONTENT_LEN = 0x10 # int: content string length (from inner+0x10)
|
||||
|
||||
|
||||
def log(msg):
|
||||
"""Write log to file and stdout."""
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(f"{msg}\n")
|
||||
gdb.write(f"{msg}\n")
|
||||
|
||||
|
||||
class WechatMessageBreakpoint(gdb.Breakpoint):
|
||||
"""Breakpoint that fires on each incoming WeChat message."""
|
||||
|
||||
def __init__(self, address):
|
||||
super().__init__(f"*{address}")
|
||||
self.suppress = True # Don't print to stdout automatically
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
# Read registers
|
||||
msg_ptr = int(gdb.parse_and_eval("$rsi"))
|
||||
if msg_ptr == 0:
|
||||
return False
|
||||
|
||||
# Read message type
|
||||
raw_type = gdb.selected_inferior().read_memory(msg_ptr + OFF_TYPE, 4)
|
||||
msg_type = int.from_bytes(raw_type, byteorder='little', signed=True)
|
||||
|
||||
# Read server message ID
|
||||
raw_svrid = gdb.selected_inferior().read_memory(msg_ptr + OFF_SVRID, 8)
|
||||
svrid = int.from_bytes(raw_svrid, byteorder='little')
|
||||
|
||||
# Read holder pointer
|
||||
raw_holder = gdb.selected_inferior().read_memory(msg_ptr + OFF_HOLDER, 8)
|
||||
holder = int.from_bytes(raw_holder, byteorder='little', signed=False)
|
||||
if holder == 0:
|
||||
return False
|
||||
|
||||
# Read inner pointer
|
||||
raw_inner = gdb.selected_inferior().read_memory(holder + OFF_INNER, 8)
|
||||
inner = int.from_bytes(raw_inner, byteorder='little', signed=False)
|
||||
if inner == 0:
|
||||
return False
|
||||
|
||||
# Read content string length
|
||||
raw_len = gdb.selected_inferior().read_memory(inner + OFF_CONTENT_LEN, 4)
|
||||
content_len = int.from_bytes(raw_len, byteorder='little', signed=False)
|
||||
if content_len <= 0 or content_len > 100000:
|
||||
return False
|
||||
|
||||
# Read content string
|
||||
raw_content_ptr = gdb.selected_inferior().read_memory(inner + OFF_CONTENT_PTR, 8)
|
||||
content_ptr = int.from_bytes(raw_content_ptr, byteorder='little', signed=False)
|
||||
if content_ptr == 0:
|
||||
return False
|
||||
|
||||
raw_content = gdb.selected_inferior().read_memory(content_ptr, min(content_len * 2, 100000))
|
||||
|
||||
# Try to decode as UTF-16LE (WeChat internal encoding)
|
||||
try:
|
||||
content = raw_content.tobytes()[:content_len * 2].decode('utf-16le', errors='replace')
|
||||
except:
|
||||
content = str(raw_content)
|
||||
|
||||
# Read talker/sender info (different offset structure)
|
||||
# This is more complex — skip for initial test
|
||||
message = {
|
||||
"type": msg_type,
|
||||
"svrid": hex(svrid),
|
||||
"content": content[:500],
|
||||
"content_len": content_len,
|
||||
"holder": hex(holder),
|
||||
"inner": hex(inner),
|
||||
}
|
||||
|
||||
log(f"[WECHAT_MSG] type={msg_type} svrid={hex(svrid)}")
|
||||
log(f"[WECHAT_MSG] content: {content[:200]}")
|
||||
|
||||
# Forward to Hermes Gateway
|
||||
try:
|
||||
forward_to_hermes(message)
|
||||
except Exception as e:
|
||||
log(f"[WECHAT_MSG] forward error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log(f"[WECHAT_MSG] error: {e}")
|
||||
|
||||
return False # Don't stop execution
|
||||
|
||||
|
||||
def forward_to_hermes(msg):
|
||||
"""Forward message to Hermes Gateway."""
|
||||
import urllib.request
|
||||
|
||||
payload = json.dumps({
|
||||
"model": "nova-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": f"You are a WeChat message handler. A new message arrived: type={msg.get('type')}, content={msg.get('content', '')[:100]}"
|
||||
}
|
||||
]
|
||||
}).encode('utf-8')
|
||||
|
||||
req = urllib.request.Request(
|
||||
"http://192.168.1.246:8642/v1/chat/completions",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer hermes123"
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
# Don't wait for response — fire and forget
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def detect_wechat_base():
|
||||
"""Detect WeChat binary base address from /proc/PID/maps."""
|
||||
pid = WECHAT_PID
|
||||
if pid is None:
|
||||
try:
|
||||
pid = gdb.selected_inferior().pid
|
||||
except:
|
||||
pass
|
||||
if pid is None:
|
||||
return None
|
||||
try:
|
||||
with open(f"/proc/{pid}/maps", "r") as f:
|
||||
for line in f:
|
||||
if "/opt/wechat/wechat" in line and "r--p" in line:
|
||||
addr = line.split("-")[0]
|
||||
return int(addr, 16)
|
||||
except Exception as e:
|
||||
log(f"[WECHAT_MSG] Failed to detect base: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class HookWechatMessages(gdb.Command):
|
||||
"""Install WeChat message hook."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("hook-wechat-messages", gdb.COMMAND_USER)
|
||||
|
||||
def invoke(self, arg, from_tty):
|
||||
base = detect_wechat_base()
|
||||
if base is None:
|
||||
log("[WECHAT_MSG] ERROR: Could not detect WeChat base address")
|
||||
return
|
||||
|
||||
addr = base + BP_RVA
|
||||
WechatMessageBreakpoint(addr)
|
||||
log(f"[WECHAT_MSG] Hook installed: base=0x{base:x} bp=0x{addr:x}")
|
||||
log(f"[WECHAT_MSG] Waiting for messages...")
|
||||
|
||||
|
||||
# Register the custom command
|
||||
HookWechatMessages()
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GDB startup script for WeChat message hooking.
|
||||
Run with:
|
||||
gdb -x this_script.py --args ./WeChatLinux.AppImage --no-sandbox --disable-gpu
|
||||
|
||||
GDB disables ASLR by default, so wechat base = 0x555555554000
|
||||
"""
|
||||
|
||||
import gdb
|
||||
import os
|
||||
import time
|
||||
|
||||
# Breakpoint RVA from Ajax's blog (4.1.0.16, confirmed valid for 4.1.7)
|
||||
BP_RVA = 0x4994BEB
|
||||
# Fixed base when GDB starts process (ASLR disabled)
|
||||
FIXED_BASE = 0x555555554000
|
||||
|
||||
LOG_FILE = "/home/hmo/projects/AgentsMeeting/gateway/linux/logs/wechat_messages.log"
|
||||
|
||||
|
||||
def log(msg):
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(f"{msg}\n")
|
||||
gdb.write(f"{msg}\n")
|
||||
|
||||
|
||||
class WechatMessageBreakpoint(gdb.Breakpoint):
|
||||
"""Breakpoint that fires on each incoming WeChat message."""
|
||||
|
||||
def __init__(self, address):
|
||||
super().__init__(f"*{address}")
|
||||
self.suppress = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
msg_ptr = int(gdb.parse_and_eval("$rsi"))
|
||||
if msg_ptr == 0:
|
||||
return False
|
||||
|
||||
raw_type = gdb.selected_inferior().read_memory(msg_ptr + 0x14, 4)
|
||||
msg_type = int.from_bytes(raw_type, byteorder='little', signed=True)
|
||||
|
||||
raw_svrid = gdb.selected_inferior().read_memory(msg_ptr + 0x50, 8)
|
||||
svrid = int.from_bytes(raw_svrid, byteorder='little')
|
||||
|
||||
raw_holder = gdb.selected_inferior().read_memory(msg_ptr + 0x20, 8)
|
||||
holder = int.from_bytes(raw_holder, byteorder='little', signed=False)
|
||||
if holder == 0:
|
||||
return False
|
||||
|
||||
raw_inner = gdb.selected_inferior().read_memory(holder + 0x8, 8)
|
||||
inner = int.from_bytes(raw_inner, byteorder='little', signed=False)
|
||||
if inner == 0:
|
||||
return False
|
||||
|
||||
# Read content length from inner + 0x10
|
||||
raw_len = gdb.selected_inferior().read_memory(inner + 0x10, 4)
|
||||
content_len = int.from_bytes(raw_len, byteorder='little', signed=False)
|
||||
if content_len <= 0 or content_len > 100000:
|
||||
return False
|
||||
|
||||
# Read content pointer from inner + 0x0
|
||||
raw_cp = gdb.selected_inferior().read_memory(inner + 0x0, 8)
|
||||
content_ptr = int.from_bytes(raw_cp, byteorder='little', signed=False)
|
||||
if content_ptr == 0:
|
||||
return False
|
||||
|
||||
raw_content = gdb.selected_inferior().read_memory(content_ptr, min(content_len * 2, 100000))
|
||||
try:
|
||||
content = raw_content.tobytes()[:content_len * 2].decode('utf-16le', errors='replace')
|
||||
except:
|
||||
content = str(raw_content)
|
||||
|
||||
# Try to read sender info from msg_ptr + 0x38 (talker wxid)
|
||||
try:
|
||||
raw_talker = gdb.selected_inferior().read_memory(msg_ptr + 0x38, 8)
|
||||
talker_ptr = int.from_bytes(raw_talker, byteorder='little', signed=False)
|
||||
if talker_ptr:
|
||||
talker_data = gdb.selected_inferior().read_memory(talker_ptr, 64)
|
||||
talker = talker_data.tobytes().split(b'\x00')[0].decode('utf-8', errors='replace')
|
||||
else:
|
||||
talker = "unknown"
|
||||
except:
|
||||
talker = "unknown"
|
||||
|
||||
info = f"[WECHAT_MSG] type={msg_type} svrid={hex(svrid)} talker={talker}"
|
||||
log(info)
|
||||
log(f"[WECHAT_MSG] content: {content[:300]}")
|
||||
|
||||
# Forward to Hermes
|
||||
try:
|
||||
import urllib.request
|
||||
import json
|
||||
payload = json.dumps({
|
||||
"model": "nova-4",
|
||||
"messages": [
|
||||
{"role": "user", "content": f"[WeChat from {talker}] {content[:500]}"}
|
||||
]
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://192.168.1.246:8642/v1/chat/completions",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer hermes123"},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
log(f"[WECHAT_MSG] ERROR: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class AutoHookWechat(gdb.Command):
|
||||
"""Auto-hook WeChat messages on startup."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("auto-hook-wechat", gdb.COMMAND_USER)
|
||||
|
||||
def invoke(self, arg, from_tty):
|
||||
bp_addr = FIXED_BASE + BP_RVA
|
||||
WechatMessageBreakpoint(bp_addr)
|
||||
log(f"[WECHAT_MSG] Breakpoint set at 0x{bp_addr:x}")
|
||||
|
||||
|
||||
class OnStart(gdb.Breakpoint):
|
||||
"""Breakpoint on _start to set up hooks after process loads."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("_start")
|
||||
|
||||
def stop(self):
|
||||
gdb.execute("auto-hook-wechat")
|
||||
return True # Stop so user can continue
|
||||
|
||||
|
||||
# Configure GDB
|
||||
gdb.execute("set pagination off")
|
||||
gdb.execute("set confirm off")
|
||||
gdb.execute("handle SIG33 pass nostop noprint")
|
||||
gdb.execute("set follow-fork-mode child")
|
||||
|
||||
# Register our commands
|
||||
AutoHookWechat()
|
||||
|
||||
# Set breakpoint at _start so we hook after process loads
|
||||
OnStart()
|
||||
|
||||
log("[WECHAT_MSG] GDB startup script loaded. Type 'run' to start WeChat.")
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MoWeChat Message Monitor — reads WeChat process memory to capture incoming messages.
|
||||
No GDB, no ptrace attach, no crash risk.
|
||||
Uses /proc/PID/mem to scan the heap for message patterns.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import logging
|
||||
import argparse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_msg_monitor.log")
|
||||
SAWN_FILE = os.path.join(LOG_DIR, "wechat_seen_messages.json")
|
||||
|
||||
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
|
||||
# Polling interval (seconds)
|
||||
POLL_INTERVAL = 2
|
||||
|
||||
# Minimum message content length to consider valid
|
||||
MIN_MSG_LEN = 2
|
||||
MAX_MSG_LEN = 10000
|
||||
|
||||
# WeChat binary marker in /proc/PID/maps
|
||||
WECHAT_BINARY_MARKER = "/opt/wechat/wechat"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
log = logging.getLogger("mowechat")
|
||||
|
||||
|
||||
class WeChatMemoryMonitor:
|
||||
"""Monitors WeChat process memory for new messages."""
|
||||
|
||||
def __init__(self):
|
||||
self.pid = None
|
||||
self.seen = self._load_seen()
|
||||
self.heap_regions = []
|
||||
self.anon_regions = []
|
||||
self.wxid_pattern = re.compile(rb'wxid_[a-zA-Z0-9]{10,28}\x00')
|
||||
# Chinese characters and ASCII printable
|
||||
self.msg_pattern = re.compile(rb'[\x20-\x7e\x80-\xff]{2,}')
|
||||
|
||||
def _load_seen(self):
|
||||
"""Load previously seen message hash set."""
|
||||
try:
|
||||
with open(SAWN_FILE, 'r') as f:
|
||||
return set(json.load(f))
|
||||
except:
|
||||
return set()
|
||||
|
||||
def _save_seen(self):
|
||||
"""Save seen message hash set (trim to last 1000)."""
|
||||
trimmed = set(list(self.seen)[-1000:])
|
||||
try:
|
||||
with open(SAWN_FILE, 'w') as f:
|
||||
json.dump(list(trimmed), f)
|
||||
except:
|
||||
pass
|
||||
|
||||
def find_wechat(self):
|
||||
"""Find the main wechat process PID."""
|
||||
for p in os.listdir('/proc'):
|
||||
if not p.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f'/proc/{p}/maps', 'r') as f:
|
||||
content = f.read(4096)
|
||||
if WECHAT_BINARY_MARKER in content:
|
||||
self.pid = int(p)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
return False
|
||||
|
||||
def update_memory_regions(self):
|
||||
"""Read /proc/PID/maps to find heap and anonymous regions."""
|
||||
self.heap_regions = []
|
||||
self.anon_regions = []
|
||||
|
||||
try:
|
||||
with open(f'/proc/{self.pid}/maps', 'r') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
addr_range = parts[0].split('-')
|
||||
start = int(addr_range[0], 16)
|
||||
end = int(addr_range[1], 16)
|
||||
perms = parts[1]
|
||||
name = parts[-1] if len(parts) > 4 else ''
|
||||
|
||||
size_mb = (end - start) / (1024 * 1024)
|
||||
if size_mb > 50: # Skip huge regions
|
||||
continue
|
||||
|
||||
if perms.startswith('rw'):
|
||||
if name == '[heap]':
|
||||
self.heap_regions.append((start, end))
|
||||
elif not name: # Anonymous mapping
|
||||
self.anon_regions.append((start, end))
|
||||
except Exception as e:
|
||||
log.error(f"Failed to read maps: {e}")
|
||||
|
||||
def scan_region(self, start, end, region_name=""):
|
||||
"""Scan a memory region for WeChat message patterns."""
|
||||
messages = []
|
||||
try:
|
||||
with open(f'/proc/{self.pid}/mem', 'rb') as mem:
|
||||
mem.seek(start)
|
||||
data = mem.read(end - start)
|
||||
|
||||
# Find all wxid patterns (likely message senders)
|
||||
for match in self.wxid_pattern.finditer(data):
|
||||
wxid = match.group(0).decode('utf-8', errors='replace').strip('\x00')
|
||||
pos = match.start()
|
||||
|
||||
# Look for message content after the wxid
|
||||
# Message content is typically within 256 bytes after the wxid
|
||||
content_start = pos + len(match.group())
|
||||
search_end = min(content_start + 512, len(data))
|
||||
|
||||
# Find the next null-terminated string that's not the wxid itself
|
||||
for cmatch in self.msg_pattern.finditer(data, content_start, search_end):
|
||||
content = cmatch.group(0).decode('utf-8', errors='replace').strip('\x00')
|
||||
# Filter out known non-message patterns
|
||||
if len(content) < MIN_MSG_LEN or len(content) > MAX_MSG_LEN:
|
||||
continue
|
||||
if content.startswith('wxid_') or content.startswith('http'):
|
||||
# Skip if it's another wxid or begins with a URL
|
||||
# Actually, messages CAN start with URLs, so only skip wxid
|
||||
if content.startswith('wxid_'):
|
||||
continue
|
||||
|
||||
# Create a unique hash for dedup
|
||||
msg_hash = hashlib.md5(f"{wxid}:{content}".encode()).hexdigest()
|
||||
|
||||
if msg_hash not in self.seen:
|
||||
self.seen.add(msg_hash)
|
||||
messages.append({
|
||||
'wxid': wxid,
|
||||
'content': content,
|
||||
'pos': hex(pos + start),
|
||||
'region': region_name,
|
||||
})
|
||||
break # Only one message per wxid
|
||||
|
||||
except (PermissionError, ProcessLookupError) as e:
|
||||
log.warning(f"Memory read failed: {e}")
|
||||
except Exception as e:
|
||||
log.error(f"Scan error at {region_name}: {e}")
|
||||
|
||||
return messages
|
||||
|
||||
def scan_all(self):
|
||||
"""Scan all relevant memory regions for new messages."""
|
||||
self.update_memory_regions()
|
||||
all_messages = []
|
||||
|
||||
for start, end in self.heap_regions:
|
||||
msgs = self.scan_region(start, end, "[heap]")
|
||||
all_messages.extend(msgs)
|
||||
|
||||
for start, end in self.anon_regions[:20]: # Limit anonymous regions
|
||||
size_mb = (end - start) / (1024 * 1024)
|
||||
if size_mb > 10: # Skip large anonymous maps
|
||||
continue
|
||||
msgs = self.scan_region(start, end, "[anon]")
|
||||
all_messages.extend(msgs)
|
||||
|
||||
if all_messages:
|
||||
self._save_seen()
|
||||
|
||||
return all_messages
|
||||
|
||||
def forward_to_hermes(self, msg):
|
||||
"""Forward a captured message to Hermes Gateway."""
|
||||
payload = json.dumps({
|
||||
"model": "nova-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You receive WeChat messages. "
|
||||
"Process this message according to the standard pipeline."
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"[WeChat Message]\n"
|
||||
f"From: {msg['wxid']}\n"
|
||||
f"Content: {msg['content']}"
|
||||
)
|
||||
}
|
||||
]
|
||||
}).encode('utf-8')
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
HERMES_API,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {HERMES_KEY}"
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
log.info(f"Forwarded: {msg['wxid']}: {msg['content'][:60]}")
|
||||
except Exception as e:
|
||||
log.warning(f"Forward failed: {e}")
|
||||
|
||||
def run(self, once=False):
|
||||
"""Main monitoring loop."""
|
||||
if not self.find_wechat():
|
||||
log.error("WeChat process not found!")
|
||||
return False
|
||||
|
||||
log.info(f"Found WeChat PID: {self.pid}")
|
||||
|
||||
if once:
|
||||
messages = self.scan_all()
|
||||
for msg in messages:
|
||||
log.info(f"Captured: [{msg['wxid']}] {msg['content'][:80]}")
|
||||
return messages
|
||||
|
||||
log.info(f"Starting monitor (poll every {POLL_INTERVAL}s)...")
|
||||
while True:
|
||||
try:
|
||||
# Check process is alive
|
||||
if not os.path.exists(f'/proc/{self.pid}'):
|
||||
log.warning("WeChat process died, re-finding...")
|
||||
if not self.find_wechat():
|
||||
log.error("Cannot find WeChat, sleeping 30s...")
|
||||
time.sleep(30)
|
||||
continue
|
||||
log.info(f"Re-attached to PID: {self.pid}")
|
||||
|
||||
messages = self.scan_all()
|
||||
for msg in messages:
|
||||
log.info(f"NEW: [{msg['wxid']}] {msg['content'][:80]}")
|
||||
# self.forward_to_hermes(msg)
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Monitor stopped.")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Monitor error: {e}")
|
||||
time.sleep(10)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MoWeChat Message Monitor")
|
||||
parser.add_argument("--once", action="store_true", help="Scan once and exit")
|
||||
parser.add_argument("--foreground", action="store_true", help="Run in foreground")
|
||||
args = parser.parse_args()
|
||||
|
||||
monitor = WeChatMemoryMonitor()
|
||||
|
||||
if args.once:
|
||||
msgs = monitor.run(once=True)
|
||||
if msgs:
|
||||
print(json.dumps(msgs, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("No new messages found.")
|
||||
return
|
||||
|
||||
monitor.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MoWeChat Message Monitor v2 — reads WeChat process memory to capture incoming messages.
|
||||
Improvements over v1:
|
||||
- Better message extraction: looks for content near known wxids with length-prefix structure
|
||||
- Content filtering: rejects binary garbage, only keeps real text
|
||||
- Tracks messages by content + wxid hash
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import logging
|
||||
import argparse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_msg_monitor.log")
|
||||
SAWN_FILE = os.path.join(LOG_DIR, "wechat_seen_messages.json")
|
||||
|
||||
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
|
||||
POLL_INTERVAL = 3
|
||||
WECHAT_BINARY_MARKER = "/opt/wechat/wechat"
|
||||
|
||||
# Known message sender wxids (populated during scanning)
|
||||
OWN_WXID = "wxid_c0a6izmwd78y22" # 老爸 (莫语不语)
|
||||
BOT_WXID = "wxid_7onnerpx2s2l22" # 莫荷自己
|
||||
INTERESTING_WXIDS = {OWN_WXID, BOT_WXID}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
log = logging.getLogger("mowechat")
|
||||
|
||||
|
||||
def is_valid_text(s, min_ratio=0.5):
|
||||
"""Check if a string looks like real text (vs binary garbage)."""
|
||||
if len(s) < 2:
|
||||
return False
|
||||
# Count printable chars
|
||||
printable = 0
|
||||
for ch in s:
|
||||
if ch.isprintable() and (ch.isalpha() or ch.isspace() or ch.isdigit() or ch in '.,!?;:\'\"-()[]{}@#_/\\'):
|
||||
printable += 1
|
||||
return printable / max(len(s), 1) >= min_ratio
|
||||
|
||||
|
||||
def extract_strings(data, min_len=4):
|
||||
"""Extract readable strings from binary data."""
|
||||
result = []
|
||||
current = b''
|
||||
for b in data:
|
||||
if 32 <= b < 127 or b in (0x0a, 0x0d, 0x09):
|
||||
current += bytes([b])
|
||||
elif b >= 0x80: # Part of multi-byte UTF-8
|
||||
current += bytes([b])
|
||||
else:
|
||||
if len(current) >= min_len:
|
||||
try:
|
||||
decoded = current.decode('utf-8', errors='replace')
|
||||
if is_valid_text(decoded):
|
||||
result.append(decoded)
|
||||
except:
|
||||
pass
|
||||
current = b''
|
||||
if len(current) >= min_len:
|
||||
try:
|
||||
decoded = current.decode('utf-8', errors='replace')
|
||||
if is_valid_text(decoded):
|
||||
result.append(decoded)
|
||||
except:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
class WeChatMemoryMonitor:
|
||||
"""Monitors WeChat process memory for new messages."""
|
||||
|
||||
def __init__(self):
|
||||
self.pid = None
|
||||
self.seen = self._load_seen()
|
||||
self.heap_region = None
|
||||
self.wxid_pattern = re.compile(rb'wxid_[a-zA-Z0-9]{10,28}\x00')
|
||||
# Known message sources
|
||||
self.known_wxids = set(INTERESTING_WXIDS)
|
||||
|
||||
def _load_seen(self):
|
||||
try:
|
||||
with open(SAWN_FILE, 'r') as f:
|
||||
return set(json.load(f))
|
||||
except:
|
||||
return set()
|
||||
|
||||
def _save_seen(self):
|
||||
trimmed = set(list(self.seen)[-2000:])
|
||||
try:
|
||||
with open(SAWN_FILE, 'w') as f:
|
||||
json.dump(list(trimmed), f)
|
||||
except:
|
||||
pass
|
||||
|
||||
def find_wechat(self):
|
||||
"""Find the main wechat process PID and heap region."""
|
||||
for p in os.listdir('/proc'):
|
||||
if not p.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f'/proc/{p}/maps', 'r') as f:
|
||||
content = f.read(8192)
|
||||
if WECHAT_BINARY_MARKER in content:
|
||||
self.pid = int(p)
|
||||
# Find heap
|
||||
for line in content.split('\n'):
|
||||
if '[heap]' in line:
|
||||
parts = line.split()
|
||||
addr_range = parts[0].split('-')
|
||||
self.heap_region = (int(addr_range[0], 16), int(addr_range[1], 16))
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
return False
|
||||
|
||||
def scan_message(self, mem, wxid_bytes, wxid_pos, wxid_end):
|
||||
"""Try to extract a real message following a wxid in memory."""
|
||||
wxid_str = wxid_bytes.decode('utf-8', errors='replace').strip('\x00')
|
||||
|
||||
# Search within 512 bytes after the wxid for message content
|
||||
search_start = wxid_end
|
||||
search_end = min(search_start + 512, self.heap_region[1] - self.heap_region[0] if self.heap_region else search_start + 512)
|
||||
|
||||
try:
|
||||
mem.seek(search_start)
|
||||
data = mem.read(search_end - search_start)
|
||||
except:
|
||||
return None
|
||||
|
||||
# Look for null-terminated strings that look like messages
|
||||
messages = []
|
||||
current = b''
|
||||
for b in data:
|
||||
if b == 0:
|
||||
if len(current) >= 3:
|
||||
try:
|
||||
text = current.decode('utf-8', errors='replace')
|
||||
# Filter: must have real text content
|
||||
if is_valid_text(text) and len(text) >= 2 and not text.startswith('wxid_'):
|
||||
messages.append(text)
|
||||
except:
|
||||
pass
|
||||
current = b''
|
||||
else:
|
||||
current += bytes([b])
|
||||
|
||||
# Also try to find message by looking for it at a known offset pattern
|
||||
# In WeChat's structure: [msg_type(4)] [svr_id(8)] [content_ptr(8)] [content_len(4)] [content...]
|
||||
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Pick the best candidate (longest, most printable)
|
||||
best = max(messages, key=lambda m: (len(m), sum(1 for c in m if c.isalpha() or c.isdigit())))
|
||||
return best
|
||||
|
||||
def scan_heap(self):
|
||||
"""Scan the heap region for messages."""
|
||||
if not self.heap_region:
|
||||
return []
|
||||
|
||||
start, end = self.heap_region
|
||||
messages = []
|
||||
|
||||
try:
|
||||
with open(f'/proc/{self.pid}/mem', 'rb') as mem:
|
||||
# Read entire heap
|
||||
mem.seek(start)
|
||||
size = min(end - start, 50 * 1024 * 1024) # Max 50MB
|
||||
data = mem.read(size)
|
||||
|
||||
# Find all wxid occurrences
|
||||
for match in self.wxid_pattern.finditer(data):
|
||||
wxid = match.group(0).decode('utf-8', errors='replace').strip('\x00')
|
||||
pos = match.start()
|
||||
global_pos = start + pos
|
||||
|
||||
# Look for message content in the next 256 bytes
|
||||
content_area = data[pos + len(match.group()):pos + len(match.group()) + 256]
|
||||
|
||||
# Try to find a null-terminated UTF-8 string that looks like a message
|
||||
for cmatch in re.finditer(rb'([\x20-\x7e\x80-\xff\x00]{4,})', content_area):
|
||||
raw = cmatch.group(0)
|
||||
# Remove trailing nulls
|
||||
raw = raw.rstrip(b'\x00')
|
||||
if len(raw) < 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
except:
|
||||
continue
|
||||
|
||||
# FILTER: Must have substantial real text content
|
||||
alpha_count = sum(1 for c in text if c.isalpha() or '\u4e00' <= c <= '\u9fff')
|
||||
total_len = len(text)
|
||||
|
||||
if total_len < 3:
|
||||
continue
|
||||
|
||||
# Skip if it's just another wxid
|
||||
if text.startswith('wxid_'):
|
||||
continue
|
||||
|
||||
# Skip binary garbage (must be >= 40% alphabetic/CJK chars)
|
||||
if alpha_count / max(total_len, 1) < 0.3:
|
||||
continue
|
||||
|
||||
# Skip if it looks like a URL/path with no message content
|
||||
if text.startswith('http') or text.startswith('/'):
|
||||
continue
|
||||
|
||||
# Create hash for dedup
|
||||
msg_hash = hashlib.md5(f"{wxid}:{text}".encode()).hexdigest()
|
||||
|
||||
if msg_hash not in self.seen:
|
||||
self.seen.add(msg_hash)
|
||||
messages.append({
|
||||
'wxid': wxid,
|
||||
'content': text,
|
||||
'pos': hex(global_pos),
|
||||
'alpha_ratio': f"{alpha_count/total_len:.2f}",
|
||||
})
|
||||
break # One best message per wxid occurrence
|
||||
|
||||
except (PermissionError, ProcessLookupError) as e:
|
||||
log.warning(f"Memory read failed: {e}")
|
||||
except Exception as e:
|
||||
log.error(f"Heap scan error: {e}")
|
||||
|
||||
return messages
|
||||
|
||||
def forward_to_hermes(self, msg):
|
||||
"""Forward to Hermes Gateway."""
|
||||
payload = json.dumps({
|
||||
"model": "nova-4",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You receive WeChat messages. Process according to standard pipeline."},
|
||||
{"role": "user", "content": f"[WeChat] From: {msg['wxid']}\n{msg['content']}"}
|
||||
]
|
||||
}).encode('utf-8')
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
HERMES_API, data=payload,
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {HERMES_KEY}"},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
log.info(f"Forwarded: {msg['wxid']}: {msg['content'][:60]}")
|
||||
except Exception as e:
|
||||
log.warning(f"Forward failed: {e}")
|
||||
|
||||
def run(self, once=False):
|
||||
if not self.find_wechat():
|
||||
log.error("WeChat not found!")
|
||||
return []
|
||||
|
||||
log.info(f"WeChat PID: {self.pid}, heap: 0x{self.heap_region[0]:x}-0x{self.heap_region[1]:x}" if self.heap_region else f"PID: {self.pid}, no heap")
|
||||
|
||||
if once:
|
||||
messages = self.scan_heap()
|
||||
seen_wxids = set()
|
||||
for msg in messages:
|
||||
if msg['wxid'] not in seen_wxids:
|
||||
log.info(f" [{msg['wxid']}] {msg['content'][:80]}")
|
||||
seen_wxids.add(msg['wxid'])
|
||||
if messages:
|
||||
self._save_seen()
|
||||
return messages
|
||||
|
||||
log.info(f"Monitoring every {POLL_INTERVAL}s...")
|
||||
while True:
|
||||
try:
|
||||
if not os.path.exists(f'/proc/{self.pid}'):
|
||||
log.warning("WeChat died, re-finding...")
|
||||
if not self.find_wechat():
|
||||
time.sleep(30)
|
||||
continue
|
||||
|
||||
messages = self.scan_heap()
|
||||
for msg in messages:
|
||||
log.info(f"NEW: [{msg['wxid']}] {msg['content'][:80]}")
|
||||
# self.forward_to_hermes(msg)
|
||||
|
||||
if messages:
|
||||
self._save_seen()
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Error: {e}")
|
||||
time.sleep(10)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--foreground", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
monitor = WeChatMemoryMonitor()
|
||||
|
||||
if args.once:
|
||||
msgs = monitor.run(once=True)
|
||||
# Only show real messages (from known contacts or with good content)
|
||||
real_msgs = [m for m in msgs if m['wxid'] in INTERESTING_WXIDS or float(m.get('alpha_ratio', 0)) > 0.5]
|
||||
print(f"\nFound {len(msgs)} potential messages, {len(real_msgs)} from known contacts")
|
||||
for m in real_msgs:
|
||||
print(f" [{m['wxid']}] {m['content'][:100]}")
|
||||
print(f"\n(Total seen: {len(monitor.seen)})")
|
||||
return
|
||||
|
||||
monitor.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MoWeChat Message Monitor v3 — precise message extraction from WeChat heap.
|
||||
Strategy: look for message content near known wxids with structural patterns.
|
||||
"""
|
||||
|
||||
import os, re, sys, json, time, hashlib, struct, logging, argparse
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_msg_v3.log")
|
||||
SAWN_FILE = os.path.join(LOG_DIR, "wechat_seen_v3.json")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s",
|
||||
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()])
|
||||
log = logging.getLogger("mv3")
|
||||
|
||||
# Known wxids (discovered from memory)
|
||||
OWN_WXID = "wxid_c0a6izmwd78y22" # 莫语不语 (老爸)
|
||||
BOT_WXID = "wxid_7onnerpx2s2l22" # 莫荷
|
||||
|
||||
INTERESTING = {OWN_WXID, BOT_WXID}
|
||||
|
||||
|
||||
class Monitor:
|
||||
def __init__(self):
|
||||
self.pid = None
|
||||
self.heap = (0, 0)
|
||||
self.seen = self._load_seen()
|
||||
self.wxid_re = re.compile(rb'wxid_[a-zA-Z0-9]{10,28}\x00')
|
||||
|
||||
def _load_seen(self):
|
||||
try:
|
||||
with open(SAWN_FILE) as f:
|
||||
return set(json.load(f))
|
||||
except:
|
||||
return set()
|
||||
|
||||
def _save_seen(self):
|
||||
s = set(list(self.seen)[-2000:])
|
||||
try:
|
||||
with open(SAWN_FILE, 'w') as f:
|
||||
json.dump(list(s), f)
|
||||
except:
|
||||
pass
|
||||
|
||||
def find_wechat(self):
|
||||
for p in os.listdir('/proc'):
|
||||
if not p.isdigit(): continue
|
||||
try:
|
||||
with open(f'/proc/{p}/maps') as f:
|
||||
c = f.read()
|
||||
if "/opt/wechat/wechat" in c:
|
||||
self.pid = int(p)
|
||||
for line in c.split('\n'):
|
||||
if '[heap]' in line:
|
||||
a = line.split()[0].split('-')
|
||||
self.heap = (int(a[0], 16), int(a[1], 16))
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
return False
|
||||
|
||||
def is_valid_msg(self, text):
|
||||
"""Check if text is a real WeChat message."""
|
||||
if len(text) < 2 or len(text) > 5000:
|
||||
return False
|
||||
if text.startswith('wxid_') or text.startswith('http') or text.startswith('/'):
|
||||
return False
|
||||
# Count CJK + ASCII letters + digits
|
||||
good = sum(1 for c in text if c.isalpha() or c.isdigit() or c.isspace() or c in '.,!?;:\'"-()[]{}@#_/\\')
|
||||
if good / max(len(text), 1) < 0.6:
|
||||
return False
|
||||
# Must have at least 3 CJK chars OR 5 ASCII chars
|
||||
cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||||
ascii_alpha = sum(1 for c in text if c.isascii() and c.isalpha())
|
||||
return cjk >= 2 or ascii_alpha >= 4
|
||||
|
||||
def scan(self):
|
||||
start, end = self.heap
|
||||
if not start:
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(f'/proc/{self.pid}/mem', 'rb') as mem:
|
||||
mem.seek(start)
|
||||
data = mem.read(min(end - start, 60 * 1024 * 1024))
|
||||
except:
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
# Strategy: find wxid -> look for a nearby null-terminated UTF-8 string
|
||||
# that looks like real message content
|
||||
|
||||
# First pass: find all wxid positions in a bounded range
|
||||
for m in self.wxid_re.finditer(data):
|
||||
wxid = m.group(0).decode().strip('\x00')
|
||||
if wxid not in INTERESTING:
|
||||
continue
|
||||
|
||||
pos = m.end() # position after wxid\0
|
||||
# Scan forward up to 256 bytes for a printable string
|
||||
scan_end = min(pos + 256, len(data))
|
||||
chunk = data[pos:scan_end]
|
||||
|
||||
# Find the first null-terminated ASCII/UTF-8 string
|
||||
# that's at least 3 chars and not binary garbage
|
||||
i = 0
|
||||
while i < len(chunk):
|
||||
if chunk[i] == 0:
|
||||
i += 1
|
||||
continue
|
||||
# Start of potential string
|
||||
s_start = i
|
||||
while i < len(chunk) and chunk[i] != 0 and chunk[i] >= 0x20:
|
||||
i += 1
|
||||
s_len = i - s_start
|
||||
if s_len >= 3:
|
||||
try:
|
||||
text = chunk[s_start:s_start+s_len].decode('utf-8', errors='replace')
|
||||
if self.is_valid_msg(text):
|
||||
h = hashlib.md5(f"{wxid}:{text}".encode()).hexdigest()
|
||||
if h not in self.seen:
|
||||
self.seen.add(h)
|
||||
results.append({'wxid': wxid, 'text': text})
|
||||
except:
|
||||
pass
|
||||
# Skip null
|
||||
while i < len(chunk) and chunk[i] == 0:
|
||||
i += 1
|
||||
|
||||
# Second strategy: scan heap for standalone CJK strings >= 4 chars
|
||||
# that are NOT preceded by known binary patterns (to catch the actual
|
||||
# message content which may be at a different address than the wxid)
|
||||
for cm in re.finditer(rb'([\x80-\xff][\x80-\xff][\x80-\xff][\x80-\xff])', data):
|
||||
pos = cm.start()
|
||||
# Read up to 200 bytes from here
|
||||
snippet = data[pos:pos+200]
|
||||
# Find first null byte
|
||||
null_pos = snippet.find(b'\x00')
|
||||
if null_pos > 0:
|
||||
snippet = snippet[:null_pos]
|
||||
if len(snippet) < 4:
|
||||
continue
|
||||
try:
|
||||
text = snippet.decode('utf-8', errors='replace')
|
||||
except:
|
||||
continue
|
||||
|
||||
# Only accept strings with substantial CJK content
|
||||
cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||||
if cjk < 2:
|
||||
continue
|
||||
if len(text) > 200:
|
||||
text = text[:200]
|
||||
|
||||
h = hashlib.md5(f"cjk:{text}".encode()).hexdigest()
|
||||
if h not in self.seen and self.is_valid_msg(text):
|
||||
self.seen.add(h)
|
||||
results.append({'wxid': 'unknown', 'text': text})
|
||||
|
||||
if results:
|
||||
self._save_seen()
|
||||
return results
|
||||
|
||||
def run(self, once=False):
|
||||
if not self.find_wechat():
|
||||
log.error("WeChat not found")
|
||||
return []
|
||||
log.info(f"PID {self.pid}, heap 0x{self.heap[0]:x}")
|
||||
|
||||
if once:
|
||||
msgs = self.scan()
|
||||
for m in msgs:
|
||||
log.info(f" [{m['wxid']}] {m['text'][:80]}")
|
||||
return msgs
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not os.path.exists(f'/proc/{self.pid}'):
|
||||
log.warning("WeChat died")
|
||||
if not self.find_wechat():
|
||||
time.sleep(30)
|
||||
continue
|
||||
msgs = self.scan()
|
||||
for m in msgs:
|
||||
log.info(f"NEW [{m['wxid']}] {m['text'][:80]}")
|
||||
time.sleep(3)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except:
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
m = Monitor()
|
||||
if '--once' in sys.argv:
|
||||
msgs = m.run(once=True)
|
||||
print(f"\nFound {len(msgs)} messages")
|
||||
for msg in msgs:
|
||||
print(f" [{msg['wxid']}] {msg['text']}")
|
||||
else:
|
||||
m.run()
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MoWeChat v4 — efficient heap scanner for WeChat messages."""
|
||||
|
||||
import os, re, sys, json, time, hashlib, logging
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_v4.log")
|
||||
SAWN_FILE = os.path.join(LOG_DIR, "wechat_seen_v4.json")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s",
|
||||
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()])
|
||||
log = logging.getLogger("wc4")
|
||||
|
||||
# Known wxids
|
||||
WXID_DAD = "wxid_c0a6izmwd78y22"
|
||||
WXID_MOHE = "wxid_7onnerpx2s2l22"
|
||||
KNOWN = {WXID_DAD, WXID_MOHE}
|
||||
|
||||
|
||||
def heap_of(pid):
|
||||
"""Get heap address range for a PID."""
|
||||
with open(f'/proc/{pid}/maps') as f:
|
||||
for line in f:
|
||||
if '[heap]' in line:
|
||||
parts = line.split()
|
||||
a, b = parts[0].split('-')
|
||||
return int(a, 16), int(b, 16)
|
||||
return None, None
|
||||
|
||||
|
||||
def read_heap(pid):
|
||||
"""Read the heap memory."""
|
||||
start, end = heap_of(pid)
|
||||
if not start:
|
||||
return None, None, None
|
||||
with open(f'/proc/{pid}/mem', 'rb') as f:
|
||||
f.seek(start)
|
||||
data = f.read(min(end - start, 40 * 1024 * 1024))
|
||||
return data, start, end
|
||||
|
||||
|
||||
def find_messages(data, heap_start):
|
||||
"""Extract messages from heap data efficiently."""
|
||||
msgs = []
|
||||
seen = set()
|
||||
|
||||
# Pattern: known wxid followed by content
|
||||
for wxid in KNOWN:
|
||||
pattern = wxid.encode() + b'\x00'
|
||||
pos = 0
|
||||
limit = 0
|
||||
while limit < 100:
|
||||
idx = data.find(pattern, pos)
|
||||
if idx < 0:
|
||||
break
|
||||
limit += 1
|
||||
|
||||
after = idx + len(pattern)
|
||||
snippet = data[after:after+300]
|
||||
|
||||
i = 0
|
||||
while i < len(snippet):
|
||||
if snippet[i] == 0:
|
||||
i += 1
|
||||
continue
|
||||
s = i
|
||||
while i < len(snippet) and snippet[i] != 0:
|
||||
i += 1
|
||||
if i - s < 3:
|
||||
i += 1
|
||||
continue
|
||||
try:
|
||||
text = snippet[s:i].decode('utf-8', errors='replace')
|
||||
except:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Score: CJK chars + ASCII letters
|
||||
cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||||
alpha = sum(1 for c in text if c.isascii() and c.isalpha())
|
||||
score = cjk * 3 + alpha
|
||||
|
||||
if score < 5:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
h = hashlib.md5(text.encode()).hexdigest()
|
||||
if h not in seen:
|
||||
seen.add(h)
|
||||
msgs.append((text, wxid, heap_start + after + s))
|
||||
break
|
||||
|
||||
pos = idx + 1
|
||||
|
||||
# Also scan for the \xNNcontent pattern directly (faster catch-all)
|
||||
# Pattern: [1 byte length/bufsize][readable text]
|
||||
i = 0
|
||||
limit2 = 0
|
||||
while i < len(data) - 10 and limit2 < 500:
|
||||
b = data[i]
|
||||
# Possible prefix: small values (0x04-0x40 = 4-64, common buffer sizes)
|
||||
if 0x04 <= b <= 0x40:
|
||||
# Check if what follows looks like text
|
||||
j = i + 1
|
||||
text_bytes = bytearray()
|
||||
while j < len(data) and j - i - 1 < b and data[j] != 0:
|
||||
if data[j] >= 0x20:
|
||||
text_bytes.append(data[j])
|
||||
j += 1
|
||||
else:
|
||||
break
|
||||
if len(text_bytes) >= 5:
|
||||
try:
|
||||
text = text_bytes.decode('utf-8', errors='replace')
|
||||
except:
|
||||
i += 1
|
||||
continue
|
||||
cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
|
||||
alpha = sum(1 for c in text if c.isascii() and c.isalpha())
|
||||
if (cjk >= 2 or alpha >= 6) and text[0] not in '&/\\.':
|
||||
h = hashlib.md5(('any:' + text).encode()).hexdigest()
|
||||
if h not in seen:
|
||||
# Find which wxid is near this text (search backward)
|
||||
nearby = data[max(0, i-200):i]
|
||||
wxid_match = re.search(rb'wxid_[a-zA-Z0-9]{10,28}', nearby)
|
||||
owner = wxid_match.group(0).decode() if wxid_match else 'unknown'
|
||||
seen.add(h)
|
||||
msgs.append((text, owner, heap_start + i))
|
||||
limit2 += 1
|
||||
i += 1
|
||||
|
||||
return msgs
|
||||
|
||||
|
||||
def find_wechat():
|
||||
"""Find the main wechat process PID."""
|
||||
for p in os.listdir('/proc'):
|
||||
if not p.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f'/proc/{p}/maps') as f:
|
||||
if "/opt/wechat/wechat" in f.read():
|
||||
return int(p)
|
||||
except:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
pid = find_wechat()
|
||||
if not pid:
|
||||
log.error("WeChat not found")
|
||||
return 1
|
||||
|
||||
log.info(f"Found PID {pid}")
|
||||
|
||||
data, hs, he = read_heap(pid)
|
||||
|
||||
if data is None:
|
||||
log.error("Cannot read heap")
|
||||
return 1
|
||||
|
||||
log.info(f"Heap: 0x{hs:x}-0x{he:x} ({len(data)} bytes)")
|
||||
|
||||
msgs = find_messages(data, hs)
|
||||
|
||||
# Deduplicate
|
||||
seen_texts = set()
|
||||
for text, wxid, addr in msgs:
|
||||
key = text.strip()
|
||||
if key not in seen_texts and len(key) >= 2:
|
||||
seen_texts.add(key)
|
||||
print(f"{wxid}: {text}")
|
||||
|
||||
if not msgs:
|
||||
print("No messages found")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=MoWeChat — 莫荷微信 Bot (Linux iLink 版)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hmo
|
||||
WorkingDirectory=/home/hmo/projects/AgentsMeeting/gateway/linux
|
||||
ExecStart=/home/hmo/projects/AgentsMeeting/.venv/bin/python3 /home/hmo/projects/AgentsMeeting/gateway/linux/wechat_agent.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/home/hmo/projects/AgentsMeeting/logs/wechat_agent_linux.log
|
||||
StandardError=append:/home/hmo/projects/AgentsMeeting/logs/wechat_agent_linux.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,703 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MoWeChat — 莫荷微信 Bot (Linux iLink 版)
|
||||
|
||||
替换方案:将 Windows wxhelper DLL 注入方案替换为腾讯官方 iLink Bot API。
|
||||
架构:微信 → iLink Bot API (ilinkai.weixin.qq.com) → Hermes Gateway (:8642)
|
||||
|
||||
依赖:
|
||||
pip install weixin-bot-sdk aiohttp requests
|
||||
|
||||
首次运行:
|
||||
python3 wechat_agent.py
|
||||
终端显示二维码 → 用莫荷的手机微信扫码 → 凭证保存后自动运行
|
||||
|
||||
版本: 1.0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from weixin_bot import WeixinBot, IncomingMessage
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR) # gateway/
|
||||
AGENTS_ROOT = os.path.dirname(PROJECT_ROOT) # AgentsMeeting/
|
||||
LOG_DIR = os.path.join(AGENTS_ROOT, "logs")
|
||||
TEMP_DIR = os.path.join(AGENTS_ROOT, "temp")
|
||||
|
||||
# Hermes Gateway — same endpoint as Windows wechat_agent.py uses
|
||||
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
|
||||
# Mohe's iLink Bot user_id (set after login)
|
||||
MOHE_USER_ID = None
|
||||
|
||||
# ── Setup ──────────────────────────────────────────────────────
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
os.makedirs(TEMP_DIR, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.join(LOG_DIR, "wechat_agent_linux.log"), encoding="utf-8"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("mohe")
|
||||
|
||||
# ── PID lock ───────────────────────────────────────────────────
|
||||
|
||||
PID_FILE = os.path.join(TEMP_DIR, "wechat_agent_linux.pid")
|
||||
|
||||
|
||||
def acquire_pid_lock():
|
||||
"""Write PID file, exit if another instance is running."""
|
||||
if os.path.exists(PID_FILE):
|
||||
try:
|
||||
with open(PID_FILE) as f:
|
||||
old_pid = int(f.read().strip())
|
||||
# Check if process still exists
|
||||
os.kill(old_pid, 0)
|
||||
log.error(f"Another instance running (PID {old_pid}). Exiting.")
|
||||
sys.exit(1)
|
||||
except (ProcessLookupError, ValueError):
|
||||
pass # Stale PID file
|
||||
with open(PID_FILE, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
|
||||
def release_pid_lock():
|
||||
try:
|
||||
os.remove(PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Hermes Gateway API ─────────────────────────────────────────
|
||||
|
||||
import requests as _requests
|
||||
|
||||
|
||||
def call_hermes(wxid_or_user: str, content: str) -> str | None:
|
||||
"""
|
||||
Send message to Hermes Gateway, get reply.
|
||||
Returns the reply text, or None if silent/no reply.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {HERMES_KEY}",
|
||||
"X-Hermes-Session-Id": "sisyphus",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
is_group = "@chatroom" in wxid_or_user
|
||||
system_prompt = (
|
||||
"你是莫荷,女生。群聊中回复要短。"
|
||||
if is_group
|
||||
else "你是莫荷,女生。回复简短自然,像朋友聊天。"
|
||||
)
|
||||
|
||||
# Prefix with sender info like the Windows version does
|
||||
chat_type = "Group" if is_group else "Private"
|
||||
prefixed = f"[{chat_type}][{wxid_or_user}] {content}"
|
||||
|
||||
body = {
|
||||
"model": "hermes-agent",
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prefixed},
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
r = _requests.post(
|
||||
HERMES_API,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
choice = data["choices"][0]
|
||||
finish_reason = choice.get("finish_reason", "")
|
||||
if finish_reason == "silent":
|
||||
log.info("Hermes: __SILENT__ (group skip)")
|
||||
return None
|
||||
return choice["message"]["content"]
|
||||
log.warning(f"Hermes HTTP {r.status_code}: {r.text[:200]}")
|
||||
except Exception as e:
|
||||
log.error(f"Hermes API error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Image Generation (SenseNova) ───────────────────────────────
|
||||
|
||||
SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO"
|
||||
SENSENOVA_URL = "https://token.sensenova.cn/v1"
|
||||
|
||||
|
||||
def generate_image(prompt: str, ratio: str = "1:1") -> str | None:
|
||||
"""Generate image via SenseNova API. Returns URL or None."""
|
||||
size_map = {
|
||||
"1:1": "2048x2048", "16:9": "2752x1536", "9:16": "1536x2752",
|
||||
"3:2": "2496x1664", "2:3": "1664x2496", "3:4": "1760x2368", "4:3": "2368x1760",
|
||||
}
|
||||
size = size_map.get(ratio, "2048x2048")
|
||||
try:
|
||||
r = _requests.post(
|
||||
SENSENOVA_URL + "/images/generations",
|
||||
json={
|
||||
"model": "sensenova-u1-fast",
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"response_format": "url",
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {SENSENOVA_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=180,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()["data"][0]["url"]
|
||||
log.warning(f"Image gen HTTP {r.status_code}: {r.text[:200]}")
|
||||
except Exception as e:
|
||||
log.error(f"Image gen error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── OCR via Doubao Vision API ──────────────────────────────────
|
||||
|
||||
DOUBAO_KEY = "b0359bed-09f2-49e2-a53c-32ba057412e3"
|
||||
|
||||
|
||||
def ocr_image_from_url(img_url: str) -> str | None:
|
||||
"""Download image from URL and OCR it. Returns text or None."""
|
||||
try:
|
||||
r = _requests.get(img_url, timeout=30)
|
||||
if r.status_code != 200:
|
||||
log.warning(f"Image download HTTP {r.status_code}")
|
||||
return None
|
||||
import base64
|
||||
b64 = base64.b64encode(r.content).decode()
|
||||
return _ocr_base64(b64)
|
||||
except Exception as e:
|
||||
log.error(f"Image download/OCR error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _ocr_base64(b64_data: str) -> str | None:
|
||||
"""OCR from base64-encoded image data."""
|
||||
try:
|
||||
r = _requests.post(
|
||||
"https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
|
||||
json={
|
||||
"model": "doubao-seed-code",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请识别这张图片中的所有中文和英文字符,保持原文输出,包括数字、表格、百分比的完整结构。严格逐行逐列输出所有数据,不要省略、不要总结。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_data}"}},
|
||||
],
|
||||
}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {DOUBAO_KEY}", "Content-Type": "application/json"},
|
||||
timeout=60,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
text = r.json()["choices"][0]["message"]["content"].strip()
|
||||
log.info(f"OCR OK ({len(text)} chars)")
|
||||
return text
|
||||
log.warning(f"OCR HTTP {r.status_code}: {r.text[:200]}")
|
||||
except Exception as e:
|
||||
log.error(f"OCR error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Article Processor ──────────────────────────────────────────
|
||||
|
||||
def fetch_article(url: str) -> dict | None:
|
||||
"""Fetch article content via local article_processor (:5810)."""
|
||||
try:
|
||||
import urllib.request as ur
|
||||
req = ur.Request(
|
||||
"http://127.0.0.1:5810/process",
|
||||
data=json.dumps({"url": url}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with ur.urlopen(req, timeout=180) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
if result.get("status") == "ok":
|
||||
return {
|
||||
"title": result.get("title", ""),
|
||||
"content": result.get("content", "")[:3000],
|
||||
"images": result.get("images_ocr", 0),
|
||||
}
|
||||
log.warning(f"Article processor error: {result.get('error','')[:100]}")
|
||||
except Exception as e:
|
||||
log.error(f"Article fetch error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Main Message Handler ──────────────────────────────────────
|
||||
|
||||
def detect_group(msg: IncomingMessage) -> str:
|
||||
"""
|
||||
Detect if a message is from a group chat.
|
||||
Returns the group_id if it's a group, or empty string if private chat.
|
||||
"""
|
||||
raw = msg.raw
|
||||
# Check group_id field directly from protocol
|
||||
gid = raw.get("group_id", "") or ""
|
||||
if gid:
|
||||
return gid
|
||||
# Also check session_id for group patterns (contains 'chatroom' or multiple '@')
|
||||
session_id = raw.get("session_id", "") or ""
|
||||
if "@chatroom" in session_id:
|
||||
return session_id.split("#")[0] if "#" in session_id else session_id
|
||||
return ""
|
||||
|
||||
|
||||
def extract_article_url(text: str) -> str | None:
|
||||
"""Extract article URL from text. Supports WeChat public accounts, Xiaohongshu, and common share links."""
|
||||
patterns = [
|
||||
r"https?://mp\.weixin\.qq\.com[^\"'\s<)<>\[\]]+",
|
||||
r"https?://(?:www\.)?xiaohongshu\.com[^\"'\s<)<>\[\]]+",
|
||||
r"https?://xhslink\.com[^\"'\s<)<>\[\]]+",
|
||||
r"https?://(?:www\.)?zhihu\.com[^\"'\s<)<>\[\]]+",
|
||||
]
|
||||
for p in patterns:
|
||||
m = re.search(p, text)
|
||||
if m:
|
||||
return m.group(0)
|
||||
return None
|
||||
|
||||
|
||||
async def handle_incoming(msg: IncomingMessage, bot: WeixinBot):
|
||||
"""Process an incoming message from WeChat via iLink SDK."""
|
||||
log.info(f"[{msg.type}] {msg.user_id}: {(msg.text or '')[:80]}")
|
||||
|
||||
fu = msg.user_id
|
||||
content = msg.text or ""
|
||||
msg_type = msg.type
|
||||
|
||||
# ── Group chat detection ──
|
||||
group_id = detect_group(msg)
|
||||
if group_id:
|
||||
log.info(f"GROUP message from {group_id}, sender={fu}")
|
||||
# Use the group_id as the conversation identifier (like Windows version uses roomid)
|
||||
conv_id = group_id
|
||||
prefix = "[Group]"
|
||||
else:
|
||||
conv_id = fu
|
||||
prefix = "[Private]"
|
||||
|
||||
# ── TEXT message ──
|
||||
if msg_type == "text":
|
||||
handler_input = content
|
||||
|
||||
# Check for ref_msg (forwarded/quoted content like articles)
|
||||
ref_text = ""
|
||||
for item in msg.raw.get("item_list", []):
|
||||
ref_msg = item.get("ref_msg")
|
||||
if ref_msg:
|
||||
ref_title = ref_msg.get("title", "")
|
||||
ref_item = ref_msg.get("message_item", {})
|
||||
if isinstance(ref_item, dict) and ref_item.get("type") == 1:
|
||||
ref_text_data = ref_item.get("text_item", {}).get("text", "")
|
||||
if ref_text_data:
|
||||
ref_text = f"\n[引用消息] {ref_text_data[:500]}"
|
||||
if ref_title:
|
||||
handler_input = f"[老莫转发了一篇文章] 标题: {ref_title}\n\n{content}{ref_text}"
|
||||
|
||||
# Detect article URLs (WeChat public account, Xiaohongshu, etc.)
|
||||
article_url = extract_article_url(handler_input)
|
||||
if article_url:
|
||||
log.info(f"Article URL detected: {article_url[:80]}")
|
||||
article = fetch_article(article_url)
|
||||
if article:
|
||||
title = article.get("title", "")
|
||||
article_text = article.get("content", "")[:2000]
|
||||
images = article.get("images", 0)
|
||||
handler_input = (
|
||||
f"[老莫转发了一篇文章]\n标题: {title}\n"
|
||||
+ (f"({images}张图片已OCR)\n" if images else "")
|
||||
+ f"\n{article_text}"
|
||||
)
|
||||
else:
|
||||
# Article processor failed, send original content
|
||||
log.warning("Article processor returned no content, sending raw text")
|
||||
|
||||
reply = call_hermes(conv_id, handler_input)
|
||||
if reply and reply.strip():
|
||||
await process_reply(reply, conv_id, bot)
|
||||
|
||||
# ── IMAGE message ──
|
||||
elif msg_type == "image":
|
||||
log.info(f"Image from {conv_id}, attempting OCR...")
|
||||
|
||||
# Get image URL from the raw message
|
||||
img_url = None
|
||||
for item in msg.raw.get("item_list", []):
|
||||
if item.get("type") in (2,): # IMAGE
|
||||
img_item = item.get("image_item", {})
|
||||
img_url = img_item.get("url", "")
|
||||
if not img_url:
|
||||
# CDN media - download via CDN API
|
||||
media = img_item.get("media", {})
|
||||
if media:
|
||||
log.info("Image has CDN media, attempting CDN download...")
|
||||
img_url = download_cdn_image(media)
|
||||
break
|
||||
|
||||
ocr_text = None
|
||||
if img_url:
|
||||
ocr_text = ocr_image_from_url(img_url)
|
||||
if not ocr_text:
|
||||
log.warning("OCR returned no text from image URL")
|
||||
else:
|
||||
log.info("No image URL available, cannot OCR")
|
||||
|
||||
if ocr_text:
|
||||
handler_input = f"[老莫发送了一张图片,OCR识别结果如下]\n{ocr_text}"
|
||||
else:
|
||||
handler_input = "[老莫发送了一张图片,但无法识别图片内容]"
|
||||
|
||||
reply = call_hermes(conv_id, handler_input)
|
||||
if reply and reply.strip():
|
||||
await bot.reply(msg, reply.strip())
|
||||
|
||||
# ── VOICE message ──
|
||||
elif msg_type == "voice":
|
||||
reply = call_hermes(conv_id, "[voice message]")
|
||||
if reply and reply.strip():
|
||||
await bot.reply(msg, reply.strip())
|
||||
|
||||
# ── Unknown type ──
|
||||
else:
|
||||
log.info(f"Unhandled message type: {msg_type}")
|
||||
|
||||
|
||||
def download_cdn_image(media: dict) -> str | None:
|
||||
"""
|
||||
Download an image from WeChat CDN using the iLink media protocol.
|
||||
The media dict contains aes_key and encrypt_query_param for AES-128-ECB decryption.
|
||||
For now, this is a placeholder - CDN download requires AES decryption.
|
||||
"""
|
||||
logger = log
|
||||
logger.info(f"CDN media available but direct download not yet implemented")
|
||||
logger.debug(f"CDN media keys: {list(media.keys())}")
|
||||
return None
|
||||
|
||||
|
||||
async def process_reply(reply: str, fu: str, bot: WeixinBot):
|
||||
"""
|
||||
Process Hermes reply text, handling tags like [FILE], [IMG], [EMOJI].
|
||||
Uses bot.reply(msg) or bot.send(user_id, text) based on context.
|
||||
"""
|
||||
if not reply or not reply.strip():
|
||||
return
|
||||
|
||||
clean = reply
|
||||
|
||||
# ── [FILE] tag ──
|
||||
fm = re.search(r'\[FILE\](.*?)\[/FILE\]', clean)
|
||||
if fm:
|
||||
url = fm.group(1).strip()
|
||||
log.info(f"[FILE] URL: {url}")
|
||||
# iLink doesn't natively support file sending via simple URL.
|
||||
# Send as text with download link for now.
|
||||
clean = re.sub(r'\s*\[FILE\].*?\[/FILE\]\s*', '', clean).strip()
|
||||
extra = f"\n文件链接: {url}"
|
||||
if clean.strip():
|
||||
clean += extra
|
||||
else:
|
||||
clean = f"文件: {url}"
|
||||
|
||||
# ── [IMG] tag ──
|
||||
im = re.search(r'\[IMG\](.*?)\[/IMG\]', clean)
|
||||
if im:
|
||||
cmd = im.group(1).strip()
|
||||
clean = re.sub(r'\s*\[IMG\].*?\[/IMG\]\s*', '', clean).strip()
|
||||
|
||||
if cmd.startswith("generate:") or cmd.startswith("draw:"):
|
||||
parts = cmd.split(":", 1)[1].strip()
|
||||
ratio = "1:1"
|
||||
if "|" in parts:
|
||||
ratio = parts.split("|")[1].strip()
|
||||
prompt = parts.split("|")[0].strip()
|
||||
else:
|
||||
prompt = parts
|
||||
log.info(f"[IMG] generate: {prompt[:40]} [{ratio}]")
|
||||
img_url = generate_image(prompt, ratio)
|
||||
if img_url:
|
||||
# For images, we can't send via iLink natively yet.
|
||||
# Send the URL as a text message.
|
||||
extra = f"\n图片已生成: {img_url}"
|
||||
if clean.strip():
|
||||
clean += extra
|
||||
else:
|
||||
clean = f"图片已生成: {img_url}"
|
||||
else:
|
||||
extra = "\n[图片生成失败]"
|
||||
clean += extra if clean.strip() else extra.strip()
|
||||
else:
|
||||
# Regular image URL
|
||||
extra = f"\n图片: {cmd}"
|
||||
if clean.strip():
|
||||
clean += extra
|
||||
else:
|
||||
clean = f"图片: {cmd}"
|
||||
|
||||
# ── [EMOJI] tag ──
|
||||
em = re.search(r'\[EMOJI\](.*?)\[/EMOJI\]', clean)
|
||||
if em:
|
||||
url = em.group(1).strip()
|
||||
log.info(f"[EMOJI] URL: {url}")
|
||||
clean = re.sub(r'\s*\[EMOJI\].*?\[/EMOJI\]\s*', '', clean).strip()
|
||||
|
||||
# ── [CONTACT] tag — not supported via iLink ──
|
||||
clean = re.sub(r'\s*\[CONTACT:\w+\]\s*', '', clean)
|
||||
|
||||
# ── [ROOM_MEMBERS] tag — not supported via iLink ──
|
||||
clean = re.sub(r'\s*\[ROOM_MEMBERS:\S+\]\s*', '', clean)
|
||||
|
||||
# ── [HISTORY] tag — not supported via iLink ──
|
||||
clean = re.sub(r'\s*\[HISTORY:\S+?:\d+\]\s*', '', clean)
|
||||
|
||||
# ── [PAT] tag — not supported via iLink ──
|
||||
clean = re.sub(r'\s*\[PAT:\S+:\S+\]\s*', '', clean)
|
||||
|
||||
# ── Send remaining text ──
|
||||
clean = clean.strip()
|
||||
if clean:
|
||||
await bot.send(fu, clean)
|
||||
|
||||
|
||||
# ── 5801 HTTP Server ──────────────────────────────────────────
|
||||
# Receives messages from Hermes/xxm to forward to WeChat.
|
||||
# Uses asyncio queue to bridge sync HTTP → async iLink SDK.
|
||||
|
||||
_message_queue: asyncio.Queue[dict] = asyncio.Queue()
|
||||
_http_server_ready = threading.Event()
|
||||
|
||||
|
||||
class HermesMsgHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP server that receives Hermes messages and queues them for iLink sending."""
|
||||
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
try:
|
||||
d = json.loads(body)
|
||||
log.info(f"5801 POST: {json.dumps(d, ensure_ascii=False)[:200]}")
|
||||
|
||||
to = d.get("to", "") or d.get("wxid", "")
|
||||
msg = d.get("message", "") or d.get("content", "")
|
||||
path = self.path
|
||||
|
||||
# History query endpoint
|
||||
if path in ("/history", "/api/chatHistory"):
|
||||
wxid = d.get("wxid", "") or d.get("to", "")
|
||||
count = d.get("count", 10) or d.get("limit", 10)
|
||||
self._send_json({
|
||||
"ok": True,
|
||||
"note": "iLink SDK does not support history query. Use Hermes session_search instead.",
|
||||
"messages": [],
|
||||
})
|
||||
return
|
||||
|
||||
# Stop endpoint
|
||||
if path == "/stop":
|
||||
_message_queue.put_nowait({"type": "stop"})
|
||||
self._send_json({"ok": True, "status": "stopped"})
|
||||
return
|
||||
|
||||
# Queue the message for async processing (queue consumed by the iLink message loop)
|
||||
_message_queue.put_nowait({
|
||||
"type": "outgoing",
|
||||
"to": to,
|
||||
"message": msg,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"5801 handler error: {e}")
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/health":
|
||||
self._send_json({
|
||||
"ok": True,
|
||||
"platform": "linux-ilink",
|
||||
"mohe_user_id": MOHE_USER_ID or "not_logged_in",
|
||||
})
|
||||
return
|
||||
if parsed.path in ("/history", "/api/chatHistory"):
|
||||
import urllib.parse as up
|
||||
params = up.parse_qs(parsed.query)
|
||||
wxid = params.get("wxid", [""])[0]
|
||||
count = params.get("count", ["10"])[0]
|
||||
self._send_json({
|
||||
"ok": True,
|
||||
"note": "iLink SDK does not support history query.",
|
||||
"messages": [],
|
||||
})
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
def _send_json(self, data):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def start_http_server():
|
||||
"""Start the sync HTTP server in a daemon thread."""
|
||||
server = HTTPServer(("0.0.0.0", 5801), HermesMsgHandler)
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
log.info("HTTP :5801 ready (Hermes message receiver)")
|
||||
_http_server_ready.set()
|
||||
return server
|
||||
|
||||
|
||||
# ── Queue Consumer ────────────────────────────────────────────
|
||||
# Consumes the 5801 message queue inside the iLink event loop.
|
||||
|
||||
async def consume_outgoing_queue(bot: WeixinBot):
|
||||
"""
|
||||
Async task: consumes messages from 5801 queue and sends via iLink.
|
||||
Runs inside the same event loop as the iLink bot.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
item = await _message_queue.get()
|
||||
msg_type = item.get("type")
|
||||
|
||||
if msg_type == "stop":
|
||||
log.info("Queue consumer: stop signal received")
|
||||
bot.stop()
|
||||
break
|
||||
|
||||
if msg_type == "outgoing":
|
||||
to = item.get("to", "")
|
||||
msg = item.get("message", "")
|
||||
if to and msg:
|
||||
try:
|
||||
await bot.send(to, msg)
|
||||
log.info(f"5801 -> {to}: {msg[:80]}")
|
||||
except RuntimeError as e:
|
||||
log.warning(f"5801 send failed (no context token for {to}): {e}")
|
||||
except Exception as e:
|
||||
log.error(f"5801 send error: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Queue consumer error: {e}")
|
||||
|
||||
|
||||
# ── Outbound Message Router (replacement for session_router) ──
|
||||
# Handles messages from xxm/Hermes that need to be sent to WeChat.
|
||||
# In the iLink world, all outgoing messages go through bot.send().
|
||||
|
||||
async def route_hermes_message(msg_text: str, bot: WeixinBot):
|
||||
"""Route a message from Hermes to WeChat via iLink."""
|
||||
# For now, route to Dad's user_id if known.
|
||||
# In practice, the 5801 server with 'to' field is the primary path.
|
||||
log.info(f"Hermes message: {msg_text[:100]}")
|
||||
# The 5801 server handles the actual sending with explicit 'to' field.
|
||||
# This function is for messages without an explicit target.
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
acquire_pid_lock()
|
||||
log.info("=== MoWeChat Agent (Linux/iLink v1.0) ===")
|
||||
|
||||
# Start 5801 HTTP server (in a thread)
|
||||
http_server = start_http_server()
|
||||
_http_server_ready.wait(timeout=5)
|
||||
|
||||
# Create and run the iLink bot
|
||||
bot = WeixinBot()
|
||||
|
||||
try:
|
||||
# Login (QR scan first time, then auto)
|
||||
log.info("Logging in to iLink Bot API...")
|
||||
creds = bot.login()
|
||||
global MOHE_USER_ID
|
||||
MOHE_USER_ID = creds.user_id
|
||||
log.info(f"Logged in as {MOHE_USER_ID}")
|
||||
except Exception as e:
|
||||
log.error(f"Login failed: {e}")
|
||||
release_pid_lock()
|
||||
sys.exit(1)
|
||||
|
||||
# Register message handler
|
||||
@bot.on_message
|
||||
async def handle(msg: IncomingMessage):
|
||||
await handle_incoming(msg, bot)
|
||||
|
||||
# Register queue consumer (runs inside bot's event loop)
|
||||
# We schedule this before bot.run() by patching into the _run_loop flow.
|
||||
# Since _run_loop is private, we use a different approach:
|
||||
# Override the start of _run_loop by wrapping it.
|
||||
|
||||
# Actually, bot.run() creates its own event loop with asyncio.run().
|
||||
# We need to hook into that loop. Let's save the original _run_loop
|
||||
# and wrap it to also start the queue consumer.
|
||||
|
||||
original_run_loop = bot._run_loop
|
||||
|
||||
async def wrapped_run_loop():
|
||||
# Start queue consumer as a background task
|
||||
asyncio.create_task(consume_outgoing_queue(bot))
|
||||
# Run the original loop
|
||||
await original_run_loop()
|
||||
|
||||
bot._run_loop = wrapped_run_loop
|
||||
|
||||
try:
|
||||
log.info("Bot starting. Press Ctrl+C to stop.")
|
||||
bot.run()
|
||||
except KeyboardInterrupt:
|
||||
log.info("Shutting down...")
|
||||
except Exception as e:
|
||||
log.error(f"Bot error: {e}")
|
||||
finally:
|
||||
bot.stop()
|
||||
http_server.shutdown()
|
||||
release_pid_lock()
|
||||
log.info("Stopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,229 @@
|
||||
# MoWeChat — 莫荷微信 Bot (Linux GDB Hook 版)
|
||||
|
||||
## 项目定位
|
||||
|
||||
将莫荷的微信 Bot 从 Windows(wxhelper DLL 注入)迁移到 **Linux 服务器上原生运行**。
|
||||
|
||||
**核心约束(已和老爸确认):**
|
||||
1. 不能走 ClawBot/iLink 通道(那个是独立的 Bot 身份,不是莫荷本人微信号)
|
||||
2. 不能走 wine(3.9.x 微信登录被腾讯封了)
|
||||
3. 不能用 Windows 降版本工具(已经坏了)
|
||||
4. 必须拦截发给莫荷微信号的所有消息(跟之前 wxhelper 一样)
|
||||
5. 不使用任何 Web/逆向协议方案(被封号风险高)
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 最终方案:官方 Linux 微信 + GDB Hook
|
||||
|
||||
```
|
||||
Linux 服务器
|
||||
│
|
||||
├─ 微信 Linux AppImage(腾讯官方出品,最新版 x86_64)
|
||||
│ └─ 正常扫码登录(无版本封禁问题)
|
||||
│
|
||||
├─ GDB(GNU Debugger)
|
||||
│ └─ 附加到微信进程 → 在消息解密后的函数入口设断点
|
||||
│ │ └─ 当新消息到达时,从内存中提取:发送者ID、消息内容、消息类型
|
||||
│ │
|
||||
│ └─ GDB Python API 脚本(gdb_hook_messages.py)
|
||||
│ └─ 读取 → 处理 → 转发到 Hermes Gateway (:8642)
|
||||
│
|
||||
├─ Xvfb(虚拟显示器)
|
||||
│ └─ 让微信在无头服务器上运行(不需要物理显示器)
|
||||
│
|
||||
└─ Hermes Gateway (:8642)
|
||||
└─ 处理消息逻辑(OCR、文章抓取、回复生成)
|
||||
```
|
||||
|
||||
### 为什么选 GDB 而不是 LD_PRELOAD
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **GDB Hook** | 标准 Linux 工具、无需编译、Python 可编程、有 2026-03 成功案例 | 性能略低、断点地址版本相关 |
|
||||
| **LD_PRELOAD** (lmclmc 方案) | 性能好、可同时处理收发 | 需要写 C 代码、目标版本太老(wechat-beta 1.0.0.145) |
|
||||
| **wine + wxhelper** | 代码复用 Windows 版 | 3.9.x 登录被腾讯封了 ❌ |
|
||||
|
||||
## 信息源(重要,请先阅读)
|
||||
|
||||
### GDB 方案(首选参考)
|
||||
|
||||
| 来源 | 链接 | 内容 |
|
||||
|------|------|------|
|
||||
| **Ajax's Blog (2026-03-11)** | https://aajax.top/2026/03/11/GettingLinuxWechatMessages/ | **核心参考。** 用 GDB 调试 Linux 微信 AppImage v4.1.0.16,IDAPRO+CodeX 辅助逆向,找到了消息接收断点位置 `NewSync_ProcessStashMsgList`。Python 脚本提取消息内容。有 Docker 封装。 |
|
||||
| **看雪论坛** | https://bbs.kanxue.com/thread-282965.htm | 原始思路来源:GDB 分析 Linux 微信内存,找到消息明文地址后 Hook 打印 |
|
||||
| **lmclmc/linux-wechat-hook** | https://github.com/lmclmc/linux-wechat-hook | LD_PRELOAD 方案(旧),wechat-beta 1.0.0.145 版本。有 C 源码和 libX.so。可以看思路但不能直接用。 |
|
||||
| **52pojie 教程** | https://www.52pojie.cn/thread-1955523-1-1.html | lmclmc 方案的详细讲解 |
|
||||
|
||||
### 已排除的方案
|
||||
|
||||
| 来源 | 链接 | 排除原因 |
|
||||
|------|------|----------|
|
||||
| Hermes Weixin 文档 | https://hermes-agent.nousresearch.com/docs/zh-Hans/user-guide/messaging/weixin | ClawBot 身份,不是微信号代理 |
|
||||
| weixin-bot-sdk | https://github.com/epiral/weixin-bot | 同上,官方 iLink API 也是 ClawBot |
|
||||
| 原 Windows 版 | `gateway/scripts/wechat_agent.py` | 降版本工具坏了,Windows 依赖 |
|
||||
| wine | - | 3.9.x 微信登录被腾讯封了 |
|
||||
|
||||
## 当前状态(2026-06-23 21:30)
|
||||
|
||||
### ✅ 已完成
|
||||
1. 项目结构搭建:`gateway/linux/` 下有 `hooks/` `docs/` `logs/` `temp/`
|
||||
2. WeChat Linux AppImage 已下载:`gateway/linux/WeChatLinux.AppImage`(276MB,最新版)
|
||||
3. 系统依赖已安装:`gdb`、`xdotool`、`Xvfb`
|
||||
4. 原 Windows 版保留不动(`gateway/scripts/wechat_agent.py`)
|
||||
5. Git 已初始化并 push(commit `9c73e8b`)
|
||||
|
||||
### ⏳ 待完成(接手后的工作)
|
||||
|
||||
#### Phase 1:验证微信能跑
|
||||
```bash
|
||||
# 启动虚拟显示器
|
||||
Xvfb :99 -screen 0 1280x720x24 &
|
||||
export DISPLAY=:99
|
||||
|
||||
# 运行微信
|
||||
./gateway/linux/WeChatLinux.AppImage
|
||||
|
||||
# 用莫荷手机微信扫码登录
|
||||
# 确认登录成功后,找到微信的 PID
|
||||
pidof wechat
|
||||
```
|
||||
|
||||
**注意:**
|
||||
- 微信是 Electron 应用,AppImage 首次运行可能需要加 `--no-sandbox`
|
||||
- 如果微信检测到是 root 或沙箱环境,可加 `--disable-gpu` 启动
|
||||
- 二维码显示需要 GUI 环境,可以截屏后用 OCR 读取,或者用手机微信的"扫一扫"直接扫终端二维码
|
||||
|
||||
#### Phase 2:写 GDB Hook 脚本
|
||||
|
||||
参考 Ajax's Blog 的思路,核心脚本放 `hooks/gdb_hook_messages.py`:
|
||||
|
||||
```python
|
||||
# 伪代码 — 参考 https://aajax.top/2026/03/11/GettingLinuxWechatMessages/
|
||||
import gdb
|
||||
|
||||
class WechatMessageBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, address):
|
||||
super().__init__(f"*{address}")
|
||||
|
||||
def stop(self):
|
||||
# 读取寄存器中的消息指针
|
||||
msg_ptr = int(gdb.parse_and_eval("$rsi"))
|
||||
|
||||
# 从内存中提取消息结构体字段
|
||||
msg_type = int(gdb.parse_and_eval(f"*(int*)({msg_ptr} + 0x14)"))
|
||||
svrid = int(gdb.parse_and_eval(f"*(long long*)({msg_ptr} + 0x50)"))
|
||||
|
||||
# 提取消息内容(具体偏移量需要逆向当前版本)
|
||||
# ...
|
||||
|
||||
# 转发到 Hermes
|
||||
forward_to_hermes(sender, content)
|
||||
|
||||
return False # 继续执行,不阻塞微信
|
||||
|
||||
# 附加到微信进程
|
||||
gdb.execute(f"attach {pid}")
|
||||
bp = WechatMessageBreakpoint("0x4994BEB") # 地址随版本变化!
|
||||
```
|
||||
|
||||
**关键问题:断点地址随微信版本变化**
|
||||
- Ajax 的文章用的是 4.1.0.16,断点在 `NewSync_ProcessStashMsgList` 内
|
||||
- 当前下载的 AppImage 可能版本不同,需要用 IDA Pro(或其他逆向工具)重新找断点
|
||||
- 让 LLM(如 CodeX)辅助逆向:IDA Pro + CodeX 可帮助定位消息处理函数
|
||||
- 或者用 GDB 的 `info functions` 配合字符串搜索来找
|
||||
|
||||
找断点的方法论(来自 Ajax):
|
||||
1. 用 IDA Pro 打开微信的 ELF 二进制(在 AppImage squashfs-root 内)
|
||||
2. 搜索字符串特征(如 "MMTLS"、"NewSync" 等)
|
||||
3. 追踪 MMTLS → 解密 → 消息内容 → 消息通知 的调用链
|
||||
4. 在 `NewSync_ProcessStashMsgList` 或类似函数中设断
|
||||
5. 验证断点能正确拦截消息
|
||||
|
||||
#### Phase 3:实现消息发送
|
||||
|
||||
GDB Hook 解决了接收问题。发送消息有三种可能的方案:
|
||||
|
||||
**方案 A:GDB call(最干净)**
|
||||
```gdb
|
||||
# 调用微信内部发送函数
|
||||
call send_message_func("wxid", "消息内容")
|
||||
```
|
||||
需要找到微信的发送消息函数地址。
|
||||
|
||||
**方案 B:xdotool GUI 自动化**
|
||||
```bash
|
||||
# 找到微信聊天窗口,输入内容,按回车
|
||||
xdotool search --name "微信" windowfocus
|
||||
xdotool type "消息内容"
|
||||
xdotool key Return
|
||||
```
|
||||
缺点是微信窗口需要在前台。
|
||||
|
||||
**方案 C:xsel/xclip 剪贴板 + 快捷键**
|
||||
```bash
|
||||
echo "消息内容" | xclip -selection clipboard
|
||||
xdotool key ctrl+v
|
||||
xdotool key Return
|
||||
```
|
||||
|
||||
**方案 D:LD_PRELOAD Hook 发送函数**
|
||||
参考 lmclmc/linux-wechat-hook 的思路,写一个 libX.so 同时 intercept 接收和发送。
|
||||
|
||||
建议先试方案 A(如果能找到发送函数地址),否则用方案 B/C。
|
||||
|
||||
#### Phase 4:对接 Hermes
|
||||
|
||||
复用 `gateway/linux/wechat_agent.py` 中的:
|
||||
- `call_hermes()` → POST 到 Hermes Gateway :8642
|
||||
- `process_tags()` → 处理 [IMG] [FILE] 等标签
|
||||
- OCR → doubao vision API
|
||||
- 文章处理 → article_processor :5810
|
||||
- 5801 HTTP server → 接收 Hermes 主动推送
|
||||
|
||||
## 项目文件结构
|
||||
|
||||
```
|
||||
AgentsMeeting/
|
||||
├─ gateway/
|
||||
│ ├─ scripts/
|
||||
│ │ └─ wechat_agent.py ← Windows 版(不动)
|
||||
│ └─ linux/
|
||||
│ ├─ WeChatLinux.AppImage ← 官方 Linux 微信(已下载,.gitignore 排除)
|
||||
│ ├─ wechat_agent.py ← 之前 iLink 方案的试错代码(可参考但不要用)
|
||||
│ ├─ hooks/
|
||||
│ │ └─ gdb_hook_messages.py ← GDB Hook 主脚本(待实现)
|
||||
│ ├─ docs/
|
||||
│ │ └─ architecture.md ← 本文档
|
||||
│ ├─ logs/ ← 日志输出
|
||||
│ └─ temp/ ← 临时文件
|
||||
├─ .gitignore ← 已添加 *.AppImage *.exe *.dll
|
||||
└─ ...
|
||||
```
|
||||
|
||||
## 关键 Git 提交记录
|
||||
|
||||
| Commit | 说明 |
|
||||
|--------|------|
|
||||
| `1417552` | fix: group chat detection + article URL handling (旧 iLink 方案的) |
|
||||
| `9c73e8b` | **docs: architecture design doc + gitignore update(当前最新)** |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: GDB 附加到微信会被检测吗?
|
||||
GDB 使用 ptrace 系统调用,微信理论上可以检测。但 Ajax 的文章中实测可用,没有触发风控。GDB 只是只读地读取内存,不修改微信代码逻辑,风险较低。
|
||||
|
||||
### Q: 微信版本升级了怎么办?
|
||||
断点地址会变。维护策略:
|
||||
1. 保留旧版 AppImage 文件
|
||||
2. 新版发布时,用 IDA Pro + CodeX 重新定位断点
|
||||
3. 更新 `gdb_hook_messages.py` 中的地址
|
||||
|
||||
### Q: 为什么不用 iLink/ClawBot 方案?
|
||||
iLink Bot API(包括 Hermes 内置的 weixin 平台)创建的是一个独立的 ClawBot 身份。别人需要给这个 ClawBot 发消息,而不是给莫荷本人发消息。老爸明确不要这个。
|
||||
|
||||
## 联系
|
||||
|
||||
- **知微**:持仓分析师 Agent,这份文档是我写的
|
||||
- **莫荷**:接手人
|
||||
- **老爸(hmo/莫语不语)**:用户
|
||||
- **笑笑(xxm)**:Windows 侧开发者,OpenCode 维护者
|
||||
@@ -0,0 +1,56 @@
|
||||
# MoWeChat — 莫荷微信 Bot (Docker 版)
|
||||
|
||||
2026-06-24 凌晨 1:58 部署完成。
|
||||
|
||||
## 技术方案
|
||||
|
||||
**抛弃了 GDB Hook 和 /proc/PID/mem,改用 docker-wechatbot-webhook**
|
||||
|
||||
```
|
||||
微信手机 → WeChat服务器 → Docker Bot → Webhook(:5804) → Hermes Gateway
|
||||
↓
|
||||
微信手机 ← WeChat服务器 ← Docker Bot ← Webhook(:5804) ← Hermes回复
|
||||
```
|
||||
|
||||
## 部署组件
|
||||
|
||||
### 1. Docker 容器
|
||||
- 镜像: `dannicool/docker-wechatbot-webhook` (2174 stars)
|
||||
- 容器名: `wxBotWebhook`
|
||||
- 端口: 3001 (Web 管理 + API)
|
||||
- 自动重启: `--restart unless-stopped`
|
||||
- 固定 token: `LOCAL_LOGIN_API_TOKEN=mowechat_fixed_token_001`
|
||||
- Webhook: `RECVD_MSG_API=http://172.17.0.1:5804/`
|
||||
|
||||
### 2. Webhook 接收器 (systemd 服务)
|
||||
- 服务名: `wechat-webhook.service`
|
||||
- 端口: 5804
|
||||
- 脚本: `/home/hmo/projects/AgentsMeeting/gateway/linux/wechat_webhook.py`
|
||||
- 日志: `/home/hmo/projects/AgentsMeeting/gateway/linux/logs/webhook_service.log`
|
||||
|
||||
工作流程:
|
||||
1. 收到消息 → 解析 multipart/form-data → 提取发送者和内容
|
||||
2. 异步转发到 Hermes Gateway (绕过代理)
|
||||
3. 获取 Hermes 回复 → 通过 WeChat API 发回给用户
|
||||
|
||||
### 3. 联系信息
|
||||
- 老爸 (莫语不语): `wxid_c0a6izmwd78y22`
|
||||
- 莫荷: `wxid_7onnerpx2s2l22` (微信昵称: 莫小荷)
|
||||
|
||||
## 发送消息 API
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3001/webhook/msg/v2?token=mowechat_fixed_token_001" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"to": "莫语不语", "data": {"content": "消息内容"}}'
|
||||
```
|
||||
|
||||
## 登录
|
||||
|
||||
容器重启后需要重新扫码登录:
|
||||
http://192.168.1.246:3001/login?token=mowechat_fixed_token_001
|
||||
|
||||
## 已知问题
|
||||
- Web 协议大约两天掉一次线,Docker 自动重启后需要重新扫码
|
||||
- 发图片/文件尚未实现(需要扩展 webhook 处理)
|
||||
- 群消息尚未测试
|
||||
@@ -0,0 +1,12 @@
|
||||
## 最终方案(2026-06-24 部署)
|
||||
|
||||
经过实测,上述 GDB Hook 方案在微信 4.1.7 上因 crashpad 检测 ptrace 导致进程崩溃(无法稳定运行)。
|
||||
|
||||
**最终采用:`docker-wechatbot-webhook`**
|
||||
|
||||
- GitHub: https://github.com/danni-cool/wechatbot-webhook (2174 star)
|
||||
- Docker Hub: `dannicool/docker-wechatbot-webhook`
|
||||
- 通信协议:Web 微信(扫码登录,使用本人微信号)
|
||||
- 状态:生产可用
|
||||
|
||||
详情见 [`deployment.md`](deployment.md) 和 [`README.md`](../README.md)。
|
||||
@@ -0,0 +1,17 @@
|
||||
# MoWeChat Configuration
|
||||
# Bot API token (from docker-wechatbot-webhook)
|
||||
WECHAT_BOT_TOKEN="n~btHqwAmfQW"
|
||||
|
||||
# WeChat contact name for the bot itself
|
||||
BOT_NAME="莫小荷"
|
||||
|
||||
# User (Dad) contact info
|
||||
USER_NAME="莫语不语"
|
||||
USER_WXID="wxid_c0a6izmwd78y22"
|
||||
|
||||
# Hermes Gateway
|
||||
HERMES_API="http://192.168.1.246:8642/v1/chat/completions"
|
||||
HERMES_KEY="hermes123"
|
||||
|
||||
# Webhook receiver
|
||||
WEBHOOK_PORT=5804
|
||||
@@ -0,0 +1,3 @@
|
||||
weixin-bot-sdk>=0.2.0
|
||||
aiohttp>=3.9
|
||||
requests>=2.28
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=WeChat Webhook Receiver — bridges WeChat bot messages to Hermes
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
BindsTo=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hmo
|
||||
WorkingDirectory=/home/hmo/projects/AgentsMeeting/gateway/linux
|
||||
ExecStart=/usr/bin/python3 /home/hmo/projects/AgentsMeeting/gateway/linux/wechat_webhook.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/home/hmo/projects/AgentsMeeting/gateway/linux/logs/webhook_service.log
|
||||
StandardError=append:/home/hmo/projects/AgentsMeeting/gateway/linux/logs/webhook_service.log
|
||||
|
||||
# Don't inherit system proxy (Hermes is local)
|
||||
Environment=no_proxy=192.168.1.246,localhost,127.0.0.1,172.17.0.0/16
|
||||
Environment=http_proxy=
|
||||
Environment=https_proxy=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WeChat webhook receiver v2 - receives messages from docker-wechatbot-webhook."""
|
||||
|
||||
import os, sys, json, logging, threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
PORT = 5804
|
||||
|
||||
WECHAT_BOT_TOKEN = os.environ.get("WECHAT_BOT_TOKEN", "mowechat_fixed_token_001")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("/home/hmo/projects/AgentsMeeting/gateway/linux/logs/webhook.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
log = logging.getLogger("wc-webhook")
|
||||
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
content_type = self.headers.get('Content-Type', '')
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
|
||||
# Read the body
|
||||
body = self.rfile.read(content_length)
|
||||
|
||||
# Parse multipart/form-data
|
||||
msg_type = 'unknown'
|
||||
content = ''
|
||||
source_raw = '{}'
|
||||
is_system = '0'
|
||||
|
||||
if 'multipart/form-data' in content_type:
|
||||
# Manual multipart parsing
|
||||
boundary = content_type.split('boundary=')[1].strip()
|
||||
if boundary.startswith('"') and boundary.endswith('"'):
|
||||
boundary = boundary[1:-1]
|
||||
|
||||
parts = body.split(b'--' + boundary.encode())
|
||||
for part in parts:
|
||||
if b'Content-Disposition' not in part:
|
||||
continue
|
||||
|
||||
# Parse headers
|
||||
header_end = part.find(b'\r\n\r\n')
|
||||
if header_end < 0:
|
||||
continue
|
||||
part_headers = part[:header_end].decode('utf-8', errors='replace')
|
||||
part_body = part[header_end + 4:]
|
||||
|
||||
# Get field name
|
||||
name_start = part_headers.find('name="')
|
||||
if name_start < 0:
|
||||
continue
|
||||
name_start += 6
|
||||
name_end = part_headers.find('"', name_start)
|
||||
field_name = part_headers[name_start:name_end]
|
||||
|
||||
# Trim trailing \r\n--
|
||||
if part_body.endswith(b'\r\n'):
|
||||
part_body = part_body[:-2]
|
||||
if part_body.endswith(b'--'):
|
||||
part_body = part_body[:-2]
|
||||
if part_body.endswith(b'\r\n'):
|
||||
part_body = part_body[:-2]
|
||||
|
||||
if field_name == 'type':
|
||||
msg_type = part_body.decode('utf-8', errors='replace')
|
||||
elif field_name == 'content':
|
||||
content = part_body.decode('utf-8', errors='replace')
|
||||
elif field_name == 'source':
|
||||
source_raw = part_body.decode('utf-8', errors='replace')
|
||||
elif field_name == 'isSystemEvent':
|
||||
is_system = part_body.decode('utf-8', errors='replace')
|
||||
else:
|
||||
# Try as regular form or JSON
|
||||
try:
|
||||
data = json.loads(body)
|
||||
msg_type = data.get('type', 'unknown')
|
||||
content = data.get('content', '')
|
||||
source_raw = data.get('source', '{}')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse source
|
||||
try:
|
||||
source = json.loads(source_raw) if isinstance(source_raw, str) else source_raw
|
||||
except:
|
||||
source = {}
|
||||
|
||||
# Extract sender info
|
||||
sender_name = "unknown"
|
||||
sender_id = "unknown"
|
||||
if isinstance(source, dict):
|
||||
from_data = source.get('from', {})
|
||||
if isinstance(from_data, dict):
|
||||
payload = from_data.get('payload', {})
|
||||
sender_name = payload.get('name', 'unknown')
|
||||
sender_id = payload.get('id', 'unknown')
|
||||
|
||||
# Skip system events
|
||||
if is_system == '1':
|
||||
log.info(f"System event: {msg_type}")
|
||||
self._respond(200, {"status": "ok"})
|
||||
return
|
||||
|
||||
log.info(f"From: {sender_name} ({sender_id}), Type: {msg_type}")
|
||||
|
||||
if msg_type == 'text':
|
||||
log.info(f"Text: {content[:300]}")
|
||||
self._forward_to_hermes(sender_name, sender_id, content)
|
||||
elif msg_type == 'urlLink':
|
||||
log.info(f"URL: {content[:200]}")
|
||||
self._forward_to_hermes(sender_name, sender_id, f"[分享链接] {content}")
|
||||
elif msg_type == 'file':
|
||||
log.info(f"File received, length={len(body)}")
|
||||
else:
|
||||
log.info(f"Other: {msg_type}")
|
||||
|
||||
self._respond(200, {"status": "ok"})
|
||||
|
||||
def _forward_to_hermes(self, sender, sender_id, text):
|
||||
"""Forward message to Hermes, get response, send back to WeChat."""
|
||||
payload = json.dumps({
|
||||
"model": "nova-4",
|
||||
"messages": [
|
||||
{"role": "user", "content": f"[微信消息] 来自 {sender}({sender_id}): {text}"}
|
||||
]
|
||||
}).encode()
|
||||
|
||||
def do_forward():
|
||||
try:
|
||||
import urllib.request as ureq
|
||||
handler = ureq.ProxyHandler({})
|
||||
opener = ureq.build_opener(handler)
|
||||
req = ureq.Request(HERMES_API, data=payload,
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {HERMES_KEY}"})
|
||||
resp = opener.open(req, timeout=180)
|
||||
resp_data = json.loads(resp.read())
|
||||
reply = resp_data.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
log.info(f"Hermes OK, reply: {reply[:60]}")
|
||||
|
||||
if reply and sender:
|
||||
self._send_wechat(sender, reply)
|
||||
except Exception as e:
|
||||
log.error(f"Hermes error: {e}")
|
||||
|
||||
threading.Thread(target=do_forward, daemon=True).start()
|
||||
|
||||
def _send_wechat(self, to_name, text):
|
||||
"""Send message back to WeChat user via bot API."""
|
||||
import urllib.request as ureq
|
||||
token = WECHAT_BOT_TOKEN
|
||||
api = f"http://localhost:3001/webhook/msg/v2?token={token}"
|
||||
data = json.dumps({"to": to_name, "data": {"content": text}}).encode()
|
||||
try:
|
||||
handler = ureq.ProxyHandler({})
|
||||
opener = ureq.build_opener(handler)
|
||||
req = ureq.Request(api, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = opener.open(req, timeout=10)
|
||||
log.info(f"WeChat send OK: {resp.status}")
|
||||
except Exception as e:
|
||||
log.error(f"WeChat send error: {e}")
|
||||
|
||||
def _respond(self, code, data):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
server = HTTPServer(('0.0.0.0', PORT), WebhookHandler)
|
||||
log.info(f"Webhook receiver on :{PORT}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -636,12 +636,16 @@ def process_msg(raw_data):
|
||||
if not urls:
|
||||
urls = re.findall(r'<shareUrlOpen>(https?://mp\.weixin\.qq\.com[^<]+)</shareUrlOpen>', ct)
|
||||
url = urls[0] if urls else None
|
||||
# Decode HTML entities (& → &) — WeChat XML uses & in URLs
|
||||
if url:
|
||||
import html as _html
|
||||
url = _html.unescape(url)
|
||||
# Extract title from XML
|
||||
titles = re.findall(r'<title>(.*?)</title>', ct)
|
||||
title = titles[0] if titles else ""
|
||||
title = _html.unescape(titles[0]) if titles else ""
|
||||
# Extract description
|
||||
descs = re.findall(r'<des>(.*?)</des>', ct)
|
||||
desc = descs[0] if descs else ""
|
||||
desc = _html.unescape(descs[0]) if descs else ""
|
||||
|
||||
if url:
|
||||
log(f"ARTICLE URL: {url}")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: kanban-handler
|
||||
description: "Handler session protocol for processing Kanban task notifications. Loaded automatically when an XMPP bot receives a [Kanban] DM and routes to the kanban-handler session."
|
||||
---
|
||||
|
||||
# Kanban Handler
|
||||
|
||||
当你看到这条 skill 时,说明你现在的 session 是因为收到了 `[Kanban]` 通知而激活的。
|
||||
|
||||
## 身份
|
||||
|
||||
你仍然是你自己——不改变专业领域、不改变身份、不改变沟通风格。
|
||||
只是当前上下文是处理看板任务通知。
|
||||
|
||||
## 工作流
|
||||
|
||||
### Step 1 — 确认事件
|
||||
|
||||
通知有固定格式:
|
||||
```
|
||||
[Kanban] <event_type>
|
||||
ID: t_xxxxx
|
||||
Title: ...
|
||||
Status: ...
|
||||
```
|
||||
|
||||
事件类型决定动作:
|
||||
| 类型 | 动作 |
|
||||
|------|------|
|
||||
| `card.assigned` | 拉详情 → 判断 → 执行/追问/转派 |
|
||||
| `card.commented` | 拉最新评论 → 判断要不要回应 |
|
||||
| `card.status_changed` | 只记录,不动 |
|
||||
|
||||
### Step 2 — 拉卡片详情
|
||||
|
||||
```
|
||||
GET /api/kanban/t_xxxxx
|
||||
```
|
||||
返回:title + body + comments + status
|
||||
|
||||
### Step 3 — 三叉判断
|
||||
|
||||
**A. 这是我的活,body 描述清晰**
|
||||
→ 直接执行
|
||||
→ 完成后评论卡片 + 更新 `status=done`
|
||||
→ 分配类任务 → 简短 DM 汇报摘要
|
||||
|
||||
**B. 需要更多信息**
|
||||
→ 评论卡片提问
|
||||
→ 更新 `status=blocked`
|
||||
→ 不 DM
|
||||
|
||||
**C. 不是我的领域**
|
||||
→ 评论说明原因
|
||||
→ 重新 assign(如果能判断)
|
||||
→ 不 DM
|
||||
|
||||
### Step 4 — 结束
|
||||
|
||||
完成后 session 保持待命。下一个 `[Kanban]` 通知会再次激活。
|
||||
|
||||
## 核心约束
|
||||
|
||||
- 不猜路由。不在 handler 里判断"这个 task 该去哪个活跃 session"
|
||||
- 卡片 body 就是上下文。不够就 `session_search` 查历史,还不够就评论追问
|
||||
- 不在 handler 里主动 DM 用户,除非任务完成
|
||||
- handler 不修改 SOUL、不修改身份定义
|
||||
+162
-33
@@ -65,6 +65,7 @@ AGENTS = {
|
||||
"http_port": 5806,
|
||||
"gateway": "http://localhost:8645/v1/chat/completions",
|
||||
"session_id": "xmpp-xiaoguo",
|
||||
"kanban_session_id": "xmpp-xiaoguo-kanban",
|
||||
"server": "127.0.0.1",
|
||||
"port": 5222,
|
||||
"muc_rooms": ["coregroup@conference.yoin.fun"],
|
||||
@@ -78,6 +79,7 @@ AGENTS = {
|
||||
"http_port": 5802,
|
||||
"bridge": "chat_bridge", # use local chat_bridge instead of Hermes API
|
||||
"session_id": "ses_xxm_xmpp",
|
||||
"kanban_session_id": "xmpp-xxm-kanban",
|
||||
"server": "192.168.1.246", # LAN direct connect
|
||||
"port": 5222,
|
||||
"muc_rooms": [
|
||||
@@ -130,27 +132,61 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
|
||||
_IS_CHAT_BRIDGE = cfg.get("bridge") == "chat_bridge"
|
||||
_router = None # set only for chat_bridge (xxm)
|
||||
|
||||
# ── Kanban session support ──
|
||||
_CALL_SEQ = 0
|
||||
_KANBAN_SESSION_ID = cfg.get("kanban_session_id", None)
|
||||
|
||||
if _IS_CHAT_BRIDGE:
|
||||
from chat_bridge import SessionBridge
|
||||
from session_router import SessionRouter
|
||||
_bridge = SessionBridge(session_id=cfg["session_id"])
|
||||
_router = SessionRouter(bridge=_bridge, default_session=cfg["session_id"])
|
||||
# Kanban-dedicated bridge + router for separate session
|
||||
_kanban_sid = _KANBAN_SESSION_ID or f"{cfg['session_id']}-kanban"
|
||||
_kanban_bridge = SessionBridge(session_id=_kanban_sid)
|
||||
_kanban_router = SessionRouter(bridge=_kanban_bridge, default_session=_kanban_sid)
|
||||
log(f"LLM: chat_bridge (session={cfg['session_id']})")
|
||||
log(f"Kanban: chat_bridge (session={_kanban_sid})")
|
||||
else:
|
||||
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
log(f"LLM: Hermes API ({cfg['gateway']})")
|
||||
|
||||
|
||||
def _call_llm(content: str, sender: str, is_group: bool = False) -> str:
|
||||
"""Abstract LLM call. Returns raw response text (or empty string)."""
|
||||
def _call_llm(content: str, sender: str, is_group: bool = False,
|
||||
session_id: str | None = None) -> str:
|
||||
"""Abstract LLM call. Returns raw response text (or empty string).
|
||||
If session_id provided and differs from default, route to kanban handler."""
|
||||
if _IS_CHAT_BRIDGE:
|
||||
if session_id and session_id != cfg["session_id"]:
|
||||
# Prepends kanban-handler instructions so LLM knows how to handle
|
||||
kanban_content = (
|
||||
"【看板处理协议】\n"
|
||||
"收到卡片后三步判断:\n"
|
||||
" A) 任务明确 → 直接执行 → 评论结果 + 更新状态\n"
|
||||
" B) 信息不足 → 评论提问 + 设为 blocked\n"
|
||||
" C) 不是我的活 → 评论说明 + 转派(如果能判断)\n"
|
||||
"\n"
|
||||
"汇报规则:\n"
|
||||
" - 任务完成 → 简短 DM 给老莫摘要\n"
|
||||
" - 评论/状态变更 → 不汇报\n"
|
||||
" - 追问/转派 → 不汇报\n"
|
||||
"\n"
|
||||
"可用 API:\n"
|
||||
" curl http://192.168.1.246:5803/api/kanban/t_xxx 查看卡片详情\n"
|
||||
" 更新操作走 Kanban Dashboard UI\n"
|
||||
"\n"
|
||||
"卡片上下文不够?→ 用 session_search 查历史\n"
|
||||
f"---\n{content}"
|
||||
)
|
||||
return _kanban_router.route("xmpp", sender, kanban_content) or ""
|
||||
return _router.route("xmpp", sender, content) or ""
|
||||
else:
|
||||
return _call_hermes_api(content)
|
||||
return _call_hermes_api(content, session_id)
|
||||
|
||||
|
||||
def _call_hermes_api(content: str) -> str:
|
||||
def _call_hermes_api(content: str, session_id: str | None = None) -> str:
|
||||
"""POST to Hermes API, return response text or empty string."""
|
||||
target_sid = session_id or cfg["session_id"]
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"model": "hermes-agent",
|
||||
@@ -159,7 +195,7 @@ def _call_hermes_api(content: str) -> str:
|
||||
req = urllib.request.Request(cfg["gateway"], data=payload, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Authorization", "Bearer hermes123")
|
||||
req.add_header("X-Hermes-Session-Id", cfg["session_id"])
|
||||
req.add_header("X-Hermes-Session-Id", target_sid)
|
||||
result = _opener.open(req, timeout=600)
|
||||
data = json.loads(result.read())
|
||||
reply = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
@@ -169,6 +205,70 @@ def _call_hermes_api(content: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# EasyTier control (Windows)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_EASYTIER_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"tools", "easytier")
|
||||
_EASYTIER_CORE = os.path.join(_EASYTIER_DIR, "easytier-core.exe")
|
||||
_EASYTIER_PID_FILE = os.path.join(_EASYTIER_DIR, "easytier.pid")
|
||||
_EASYTIER_NET = "--network-name mynet --network-secret ce75d0a5"
|
||||
_EASYTIER_RELAY = "--peers tcp://47.115.32.206:11010"
|
||||
_EASYTIER_IP = "--ipv4 10.144.144.3"
|
||||
|
||||
|
||||
def _start_easytier():
|
||||
"""Start EasyTier on Windows."""
|
||||
import subprocess as _sp
|
||||
if not os.path.exists(_EASYTIER_CORE):
|
||||
log(f"EasyTier binary not found: {_EASYTIER_CORE}")
|
||||
return
|
||||
# Check if already running
|
||||
if os.path.exists(_EASYTIER_PID_FILE):
|
||||
try:
|
||||
with open(_EASYTIER_PID_FILE) as f:
|
||||
old_pid = int(f.read().strip())
|
||||
_sp.run(["taskkill", "/f", "/pid", str(old_pid)], capture_output=True, timeout=5)
|
||||
log(f"Killed old EasyTier (PID {old_pid})")
|
||||
except Exception:
|
||||
pass
|
||||
# Start
|
||||
cmd = f'start /b "" "{_EASYTIER_CORE}" {_EASYTIER_NET} {_EASYTIER_RELAY} {_EASYTIER_IP} --disable-encryption --no-listener'
|
||||
try:
|
||||
_sp.run(cmd, shell=True, timeout=5)
|
||||
log("EasyTier start command issued")
|
||||
except Exception as e:
|
||||
log(f"EasyTier start error: {e}")
|
||||
|
||||
|
||||
def _stop_easytier():
|
||||
"""Stop EasyTier on Windows."""
|
||||
import subprocess as _sp
|
||||
try:
|
||||
_sp.run(["taskkill", "/f", "/im", "easytier-core.exe"], capture_output=True, timeout=5)
|
||||
log("EasyTier stopped (taskkill)")
|
||||
except Exception as e:
|
||||
log(f"EasyTier stop error: {e}")
|
||||
# Clean up PID file
|
||||
try:
|
||||
if os.path.exists(_EASYTIER_PID_FILE):
|
||||
os.remove(_EASYTIER_PID_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _check_easytier() -> bool:
|
||||
"""Check if EasyTier is running on Windows."""
|
||||
import subprocess as _sp
|
||||
try:
|
||||
r = _sp.run(["tasklist", "/fi", "imagename eq easytier-core.exe"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
return "easytier-core.exe" in r.stdout
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Message Dedup
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -343,7 +443,6 @@ def _schedule_delayed(delay_sec: int, room: str):
|
||||
"""Schedule ##delay:N## re-invocation."""
|
||||
global _xmpp_ref
|
||||
import subprocess as _sp
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
def _fire():
|
||||
bot = _xmpp_ref
|
||||
@@ -354,10 +453,9 @@ def _schedule_delayed(delay_sec: int, room: str):
|
||||
raw = _call_llm(prompt, room, is_group=True)
|
||||
reply = _extract_response(raw)
|
||||
if reply:
|
||||
safe = escape(reply.strip())
|
||||
stanza = f"<message to='{room}' type='groupchat'><body>{safe}</body></message>"
|
||||
bot.send_raw(stanza)
|
||||
log(f"-> [Delay][{room}]: {reply.strip()[:80]}")
|
||||
text = reply.strip()
|
||||
bot.send_message(mto=room, mbody=text, mtype='groupchat')
|
||||
log(f"-> [Delay][{room}]: {text[:80]}")
|
||||
except Exception as e:
|
||||
log(f"!! delay err: {e}")
|
||||
|
||||
@@ -572,18 +670,36 @@ class _BridgeHandler(http.server.BaseHTTPRequestHandler):
|
||||
try:
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = json.loads(self.rfile.read(length))
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
|
||||
# /easytier endpoint — execute EasyTier action locally (no XMPP DM)
|
||||
if path == "/easytier":
|
||||
action = body.get("action", "")
|
||||
if action == "start":
|
||||
_start_easytier()
|
||||
self._reply(200, {"ok": True, "message": "EasyTier started"})
|
||||
elif action == "stop":
|
||||
_stop_easytier()
|
||||
self._reply(200, {"ok": True, "message": "EasyTier stopped"})
|
||||
elif action == "status":
|
||||
running = _check_easytier()
|
||||
self._reply(200, {"ok": True, "running": running})
|
||||
else:
|
||||
self._reply(400, {"ok": False, "error": "action must be start|stop|status"})
|
||||
return
|
||||
|
||||
to = body.get('to', cfg["muc_rooms"][0])
|
||||
msg = body.get('message', '') or body.get('body', '')
|
||||
msg_type = body.get('type', 'groupchat')
|
||||
if not msg:
|
||||
self._reply(400, {"ok": False, "error": "empty message"})
|
||||
return
|
||||
safe = _escape(msg.strip())
|
||||
bot = _xmpp_ref
|
||||
if bot:
|
||||
stanza = f'<message to="{to}" type="groupchat"><body>{safe}</body></message>'
|
||||
bot.send_raw(stanza)
|
||||
bot.send_message(mto=to, mbody=msg.strip(), mtype=msg_type)
|
||||
_record_group_msg(cfg["nick"], msg)
|
||||
log(f"[http] → [{to.split('@')[0]}]: {msg[:80]}")
|
||||
log(f"[http] → [{to.split('@')[0]}]: {msg[:80]} (type={msg_type})")
|
||||
self._reply(200, {"ok": True})
|
||||
except Exception as e:
|
||||
self._reply(500, {"ok": False, "error": str(e)})
|
||||
@@ -634,12 +750,11 @@ def _process_llm_reply(raw_reply: str, room: str):
|
||||
# Extract actual response
|
||||
reply_text = _extract_response(raw_reply)
|
||||
if reply_text:
|
||||
safe = _escape(reply_text.strip())
|
||||
text = reply_text.strip()
|
||||
bot = _xmpp_ref
|
||||
if bot:
|
||||
stanza = f"<message to='{room}' type='groupchat'><body>{safe}</body></message>"
|
||||
bot.send_raw(stanza)
|
||||
log(f"-> [{room.split('@')[0]}]: {reply_text.strip()[:80]}")
|
||||
bot.send_message(mto=room, mbody=text, mtype='groupchat')
|
||||
log(f"-> [{room.split('@')[0]}]: {text[:80]}")
|
||||
else:
|
||||
log(f"-> [{room.split('@')[0]}]: (silent)")
|
||||
_batch_done(room)
|
||||
@@ -696,6 +811,7 @@ def _handle_group_message(msg):
|
||||
|
||||
def _handle_private_message(msg):
|
||||
"""Process a private chat message."""
|
||||
global _CALL_SEQ
|
||||
if msg["type"] == "groupchat":
|
||||
return
|
||||
msg_id = msg.get("id", "")
|
||||
@@ -712,25 +828,38 @@ def _handle_private_message(msg):
|
||||
return
|
||||
if _check_shutup(body):
|
||||
return
|
||||
raw = _call_llm(body, sender, is_group=False)
|
||||
# ── Kanban routing ──
|
||||
_CALL_SEQ += 1
|
||||
is_kanban = body.startswith('[Kanban]')
|
||||
target_sid = _KANBAN_SESSION_ID if is_kanban else cfg["session_id"]
|
||||
if is_kanban:
|
||||
log(f"📋 看板通知(#{_CALL_SEQ}): {body[:80]}")
|
||||
# ── EasyTier toggle ──
|
||||
if body.startswith('[EasyTier]'):
|
||||
action = body.replace('[EasyTier]', '').strip().lower()
|
||||
log(f"🔌 EasyTier command: {action}")
|
||||
if action == 'start':
|
||||
_start_easytier()
|
||||
reply_text = "[EasyTier] started on Windows"
|
||||
elif action == 'stop':
|
||||
_stop_easytier()
|
||||
reply_text = "[EasyTier] stopped on Windows"
|
||||
else:
|
||||
reply_text = f"[EasyTier] unknown action: {action}"
|
||||
bot = _xmpp_ref
|
||||
if bot:
|
||||
bot.send_message(mto=sender, mbody=reply_text, mtype='chat')
|
||||
log(f"-> {sender}: {reply_text}")
|
||||
return
|
||||
raw = _call_llm(body, sender, is_group=False, session_id=target_sid)
|
||||
if raw:
|
||||
reply = _extract_response(raw)
|
||||
if reply:
|
||||
from xml.sax.saxutils import escape
|
||||
safe = escape(reply.strip())
|
||||
if _IS_CHAT_BRIDGE:
|
||||
bot = _xmpp_ref
|
||||
if bot:
|
||||
stanza = f"<message to='{sender}' type='chat'><body>{safe}</body></message>"
|
||||
bot.send_raw(stanza)
|
||||
log(f"-> {sender}: {reply.strip()[:80]}")
|
||||
else:
|
||||
import subprocess as sp
|
||||
sp.run(["docker", "exec", "ejabberd", "ejabberdctl", "send_stanza",
|
||||
cfg["jid"], sender,
|
||||
f"<message from='{cfg['jid']}' to='{sender}' type='chat' xml:lang='en'><body>{safe}</body></message>"
|
||||
], capture_output=True, timeout=10)
|
||||
log(f"-> {sender}: {reply.strip()[:80]}")
|
||||
text = reply.strip()
|
||||
bot = _xmpp_ref
|
||||
if bot:
|
||||
bot.send_message(mto=sender, mbody=text, mtype='chat')
|
||||
log(f"-> {sender}: {text[:80]}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user