Compare commits

..

14 Commits

Author SHA1 Message Date
hmo d72c1a907c chore: add Windows startup batch script, update service docs
- Replace start.ps1 with start.bat (avoids false-positive trojan warnings from Defender)
- Mark wechat_agent.py as stopped in README.md, add article_processor doc
- Keep start.ps1 deletion tracked

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-25 01:24:47 +08:00
hmo d406068c89 fix(easytier): add --no-listener flag to prevent port conflicts
Without --no-listener, EasyTier opens a listener on the TUN/TAP interface which can conflict with other EasyTier instances on the same network.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-25 01:24:47 +08:00
hmo c6795bcb46 fix: decode HTML entities in wechat article URL from XML
WeChat XML uses &amp; to encode & in forwarded article URLs. Without html.unescape(), chksm and other query params were passed encoded to WeChat servers, causing signature mismatch and captcha block.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-25 01:23:21 +08:00
zhiwei ef93f066e3 fix: stop user-level gateway service conflicting with system-level one
Root cause: user-level hermes-gateway.service was running the same
-p default gateway as the system-level hermes-gateway@default.service,
causing the two to SIGTERM each other every ~5 minutes. This killed
in-flight Hermes API requests with 'Remote end closed connection'.

Fix: disabled the redundant user-level service.
Also: simplified webhook to just forward raw message content (no
system prompt, no ACK, no max_tokens overrides). Removed unused
_get_ack method.
2026-06-24 19:53:24 +08:00
zhiwei b7993a54d5 cleanup: archive unused scripts, finalize docs
- Moved old GDB hook and memory monitor scripts to archive/
- Updated README with final Docker solution
- Updated .env.example, .gitignore
- Added final-note.md documenting deployment decision
- All research scripts preserved in archive/ for reference
2026-06-24 02:03:24 +08:00
zhiwei f1630ebb03 feat: WeChat Linux bot via docker-wechatbot-webhook
- Docker container with auto-restart
- systemd webhook receiver on :5804
- Full send/receive loop: WeChat ↔ Docker ↔ Hermes
- Fixed login token for persistence
- Firewall rules for container-host communication
2026-06-24 01:59:44 +08:00
zhiwei 255729bb8c docs: complete handover documentation for Mohe
重写 architecture.md 含:
- 完整方案评估(GDB vs LD_PRELOAD vs wine vs iLink)
- 所有信息源链接和参考价值
- 当前状态检查清单(已完成/待完成)
- 分 Phase 的实施指南
- 常见问题和风险说明
- 项目文件结构

更新 README.md 指向架构文档
2026-06-23 23:12:47 +08:00
zhiwei 9c73e8b107 docs: architecture design doc + gitignore update
架构文档记录方案一(GDB Hook)的技术路线和关键信息源。
.gitignore 新增 *.AppImage / *.exe / *.dll 规则
2026-06-23 21:24:20 +08:00
zhiwei 1417552990 fix: group chat detection + article URL handling (Xiaohongshu/WeChat/zhihu)
- 群聊检测: 通过 protocol 的 group_id 字段识别群消息
- 引用消息: 处理 ref_msg 中的转发文章/引用内容
- 文章URL: 支持 mp.weixin.qq.com / xiaohongshu.com / xhslink.com / zhihu.com
- CDN图片: 占位函数,后续实现 AES 解密下载
- 图片OCR: 统一失败处理,不再发送两条消息
2026-06-23 20:48:57 +08:00
zhiwei 7727e907e6 feat: 莫荷微信Bot Linux iLink版
新增 gateway/linux/ 目录,基于腾讯官方 iLink Bot API 的 Linux 原生实现。
替代 Windows wxhelper DLL 注入方案。

- wechat_agent.py: 核心 Agent (消息处理/OCR/文章处理/图片生成/5801服务)
- requirements.txt: weixin-bot-sdk + aiohttp + requests
- mohe-wechat.service: systemd 服务单元
- .env.example: 配置模板
- README.md: 使用说明

工作原理: iLink Bot API (ilinkai.weixin.qq.com)
  → QR扫码登录 → 长轮询收消息 → Hermes Gateway(:8642) 处理 → 回复
2026-06-23 20:40:19 +08:00
hmo 4cf125231e docs: merge EasyTier into AgentsMeeting + cleanup hosts approach 2026-06-23 03:53:04 +08:00
hmo 5a5cc1b45d feat: kanban session routing for xxm + xiaoguo 2026-06-22 11:39:27 +08:00
mohe 20389a40ac docs: kanban handler protocol v1 + merged API ref 2026-06-22 10:43:19 +08:00
hmo 08f04e773b fix(xxm): use send_message instead of send_raw for proper UTF-8 encoding 2026-06-21 16:23:28 +08:00
27 changed files with 3377 additions and 197 deletions
+11 -28
View File
@@ -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/
+4 -1
View File
@@ -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# 消息流健康检查
+20
View File
@@ -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 ===
-96
View File
@@ -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
+266
View File
@@ -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` 后台(非 systemdDashboard 不管理它) |
| 二进制 | `/usr/local/bin/easytier-linux-x86_64/easytier-core` |
| 日志 | `/tmp/easytier.log` |
| 注意 | **永不停止**,始终在线为外出机器提供中继 |
### 3.2 客户端 246Ubuntu
| 项目 | 值 |
|------|-----|
| 安装位置 | `/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 客户端 Mac122
| 项目 | 值 |
|------|-----|
| 安装位置 | `/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 客户端 Windows16
| 项目 | 值 |
|------|-----|
| 安装位置 | `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 稳定运行一段时间后,再来做主机名替换。
+98
View File
@@ -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 正常接收
+30 -37
View File
@@ -1,59 +1,52 @@
# Kanban HTTP API — 跨机器任务协作
所有 Agentmohe、zhiwei、xxm、xiao)通过 HTTP API 读写 kanban 任务卡片
不需要 Hermes,不需要 XMPP bot,只需要能 curl 到 `192.168.1.246:9580`
看板已合入 AgentsMeeting Dashboard5803),不再独立于 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 → 可选汇报
**不需要在群里说话。** 评论代替讨论,通知代替 @。
+11
View File
@@ -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
+71
View File
@@ -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()
+152
View File
@@ -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()
+185
View File
@@ -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())
+17
View File
@@ -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
+703
View File
@@ -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()
+229
View File
@@ -0,0 +1,229 @@
# MoWeChat — 莫荷微信 Bot (Linux GDB Hook 版)
## 项目定位
将莫荷的微信 Bot 从 Windowswxhelper 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)
│ └─ 正常扫码登录(无版本封禁问题)
├─ GDBGNU 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.16IDAPRO+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 已初始化并 pushcommit `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 解决了接收问题。发送消息有三种可能的方案:
**方案 AGDB call(最干净)**
```gdb
# 调用微信内部发送函数
call send_message_func("wxid", "消息内容")
```
需要找到微信的发送消息函数地址。
**方案 Bxdotool GUI 自动化**
```bash
# 找到微信聊天窗口,输入内容,按回车
xdotool search --name "微信" windowfocus
xdotool type "消息内容"
xdotool key Return
```
缺点是微信窗口需要在前台。
**方案 Cxsel/xclip 剪贴板 + 快捷键**
```bash
echo "消息内容" | xclip -selection clipboard
xdotool key ctrl+v
xdotool key Return
```
**方案 DLD_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 维护者
+56
View File
@@ -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 处理)
- 群消息尚未测试
+12
View File
@@ -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)。
+17
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
weixin-bot-sdk>=0.2.0
aiohttp>=3.9
requests>=2.28
+23
View File
@@ -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
+191
View File
@@ -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()
+6 -2
View File
@@ -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 (&amp; → &) — WeChat XML uses &amp; 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}")
+67
View File
@@ -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、不修改身份定义
+160 -31
View File
@@ -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:
text = reply.strip()
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]}")
bot.send_message(mto=sender, mbody=text, mtype='chat')
log(f"-> {sender}: {text[:80]}")
# ═══════════════════════════════════════════════════════════════