Compare commits
13
Commits
2a48e0dfcf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fba23dc4 | ||
|
|
f75fc9b414 | ||
|
|
58bc7c1db1 | ||
|
|
a21e36a6a9 | ||
|
|
98b19152f1 | ||
|
|
b1e77a95a4 | ||
|
|
fd9668103a | ||
|
|
bcd2e84ed8 | ||
|
|
5d78509d54 | ||
|
|
ebbdb82781 | ||
|
|
e9c54923ea | ||
|
|
336fb1d241 | ||
|
|
d5850ce18a |
@@ -0,0 +1,59 @@
|
||||
# CHANGELOG — wechat-ops
|
||||
|
||||
## v0.2.0 (2026-09-29)
|
||||
|
||||
新增 Linux 云服务器常驻 Agent 路线(与 macOS 视觉路线并行,共享视觉闭环纪律):
|
||||
|
||||
- `scripts/agent.py`:GLM 视觉值守 Agent(8秒盯屏→红点检测→白名单→读会话→拟回复→发送),内嵌安全闸门:发送前视觉核验窗口标题(空标题拒绝)、白名单外零操作、限速、静默时段、敏感词回退、PAUSE 急停、自愈状态机(托盘恢复/透明壳重启/自动登录/过渡态保护)
|
||||
- `scripts/setup-server.sh`:一键环境搭建(xfce+TigerVNC+noVNC+中文字体+swap+微信Linux版,幂等)
|
||||
- `scripts/wechat-agent.service` + `scripts/config-template.json`(已脱敏)
|
||||
- `scripts/check-skill.sh`:skill 守卫(结构/引用/凭证/隐私/断言/编码/语法/CHANGELOG/包一致性)
|
||||
- `references/linux-server.md`:部署与运维指南
|
||||
- `references/pitfalls-linux.md`:17 条实战踩坑(xclip 挂起、pkill 自杀、托盘隐藏、置顶折叠、多账号陷阱等)
|
||||
- `references/ops-workflow.md`:skill 建设与维护工作流(基线优先/形式匹配失败类型/守卫/三方同步/证据分级/复盘)
|
||||
- SKILL.md 扩展为双平台入口,description 覆盖 Linux 值守场景触发词
|
||||
|
||||
## v0.1.x (历史)
|
||||
|
||||
macOS 视觉自动化技能初版:vision.py + 单文件 SKILL.md(坐标绝不落盘、每步验证、朋友圈发布全流程案例)。
|
||||
|
||||
## v0.2.1 (2026-09-29)
|
||||
|
||||
- agent.py: 消息合并去抖(merge_wait)、AI时间感知(当前时间注入prompt)、心跳时间持久化(重启不重发)、修复 Python3 推导式变量泄漏导致的红点状态复位崩溃
|
||||
- pitfalls-linux.md: 第19条(发文件需先聚焦输入区,否则 Send 点击被吞)
|
||||
- config-template: 新增 merge_wait
|
||||
|
||||
## v0.3.0 (2026-09-29) — 迭代1
|
||||
|
||||
学自同类项目(设计借鉴,无代码复制)落地五项:
|
||||
|
||||
- **每会话独立 Prompt**:config.personas 按会话名覆盖默认人设(#4)
|
||||
- **聊天记忆**:memory/<会话>.md 落盘,最近40行注入 prompt,每10次交互 GLM 压缩摘要(#5)
|
||||
- **群 @ 检测**:群会话仅被 @ self_nick 时响应,否则仅记录(#6)
|
||||
- **链接阅读**:检测对方消息 URL→抓正文(3000字)→结合内容回复,失败降级(#8)
|
||||
- **FT 图标模板匹配**:颜色候选+9x9 模板最小距离择优,核验成功后自学习模板(#2,待实测关闭)
|
||||
|
||||
其他:config 新增 self_nick/personas/merge_wait;send 前消息合并去抖;AI 时间感知;心跳持久化防重启重发;Python3 推导式崩溃修复(红点状态复位)。
|
||||
|
||||
## v0.3.1 (2026-09-29)
|
||||
|
||||
- **知识库接入**: kb/ 目录 + 轻量检索(kb_context 按关键词重合度取段落),群聊按 kb_chats 配置注入「AI教育杨老师」知识库参考;群人设升级为 AI 知识助教
|
||||
- ima 知识库 MCP 直连列入升级票(#13)
|
||||
|
||||
## v0.3.2 (2026-09-29)
|
||||
|
||||
- **ima 知识库 OpenAPI 直连**(替代手动导出):双头认证(ima-openapi-clientid/apikey)、search_knowledge_base 解析知识库 ID(兼容实际返回 kb_id/kb_name 字段)、jieba 分词拆词搜索、get_media_info 取原文(编码探测防乱码)、文件检索兜底
|
||||
- 凭证仅存服务器 config(600),仓库模板留空
|
||||
- 群助人设升级为「AI教育杨老师」AI知识助教,kb_chats 控制生效范围
|
||||
|
||||
## v0.4.1 (2026-09-29)
|
||||
|
||||
- N-03 完成:6080 安全直连上线(nginx TLS+BasicAuth 反代,自签证书,htpasswd 哈希),公网实测 401/200 正常,明文 400 拒绝
|
||||
- websockify 迁移至 127.0.0.1:6081,6080 公网面由 nginx 接管
|
||||
- linux-server.md 补充安全直连部署说明
|
||||
|
||||
## v0.3.3 (2026-09-29)
|
||||
|
||||
- ima 检索关键词修复:停用词扩充(能把/告诉/我们/内容/资料等元词),关键词按词长降序+中文前缀候选(「四会开」→「四会」),查询预算 4→6
|
||||
- 实测:真实问题「能把9月28号我们在四会开会议的内容吗」命中会议总结等文档;compose 试答发现知识库无 9/28 会议记录时 GLM 会脑补——确认不发送,兜底话术行为正确
|
||||
- 知识库待补:9月28 四会会议笔记
|
||||
@@ -1,6 +1,6 @@
|
||||
# WeChat Ops
|
||||
|
||||
用「**截图 → 视觉识别 → 动作 → 截图验证**」的闭环操作 macOS 电脑,代替人手点按钮、输文字、切窗口、走界面流程。
|
||||
用「**截图 → 视觉识别 → 动作 → 截图验证**」的闭环操作电脑(macOS 本机路线 + Linux 云服务器常驻 Agent 路线),代替人手点按钮、输文字、切窗口、走界面流程。
|
||||
|
||||
适用于 Claude Code、Codex 等支持 skill 的 AI 编程助手。
|
||||
|
||||
@@ -87,3 +87,10 @@ macOS Retina 屏上,**截图分辨率和点击分辨率不是一回事**:`sc
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## 维护纪律(skill-ops 工作流)
|
||||
|
||||
本仓库按 [references/ops-workflow.md](references/ops-workflow.md) 维护:失败基线优先、
|
||||
安全闸门固化进代码、证据分级表述(已验证:证据 / 未验证:待确认)、
|
||||
守卫检查(`bash scripts/check-skill.sh .`)、CHANGELOG 版本化。
|
||||
改动 scripts/agent.py 时,服务器部署实例必须回填仓库后再发布。
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
---
|
||||
name: wechat-ops
|
||||
description: 用「截图 → 视觉识别 → 动作 → 截图验证」的闭环操作 macOS 电脑,代替人手点按钮、输文字、切窗口、走界面流程。只要用户想让你「帮我点一下」「操作我的电脑」「自动发个朋友圈/消息」「把这段文字输入到某个软件」「帮我走一遍这个界面流程」「模拟点击」「识图点击」「截图找按钮」「GUI 自动化」「RPA」,都必须使用本技能——即使用户没说要"用技能"。核心特征:坐标绝不写死落盘,每次实时看图现算、事后验证。若目标是在 Figma 画布内做设计,请改用 Figma 系列技能。
|
||||
description: 用「截图 → 视觉识别 → 动作 → 截图验证」的闭环操作电脑,代替人手点按钮、输文字、切窗口、走界面流程。只要用户想让你「帮我点一下」「操作我的电脑」「自动发个朋友圈/消息」「把这段文字输入到某个软件」「帮我走一遍这个界面流程」「模拟点击」「识图点击」「截图找按钮」「GUI 自动化」「RPA」,都必须使用本技能——即使用户没说要"用技能"。核心特征:坐标绝不写死落盘,每次实时看图现算、事后验证。若目标是在 Linux 云服务器上 7x24 挂机微信、微信自动回复、微信值守机器人,走 references/linux-server.md 的常驻 Agent 路线。若目标是在 Figma 画布内做设计,请改用 Figma 系列技能。
|
||||
|
||||
---
|
||||
|
||||
# WeChat Ops
|
||||
|
||||
用视觉驱动的方式操作 macOS 电脑:**看屏幕 → 判断点哪里 → 动手 → 再看一眼确认**。
|
||||
用视觉驱动的方式操作电脑:**看屏幕 → 判断点哪里 → 动手 → 再看一眼确认**。
|
||||
|
||||
## 零、两条路线,先选对
|
||||
|
||||
| | 路线 A:macOS 本机(本文件主体) | 路线 B:Linux 云服务器(references/linux-server.md) |
|
||||
|---|---|---|
|
||||
| 场景 | 操作你眼前电脑的一次性界面任务 | 微信 7x24 挂机、自动回复值守 |
|
||||
| 工具 | `scripts/vision.py`(pyautogui) | `scripts/agent.py`(GLM 视觉 Agent,systemd 常驻) |
|
||||
| 坐标哲学 | **易耗品**:每次实时定位,绝不落盘 | **固定资产**:虚拟屏分辨率+最大化窗口固定,一次校准长期有效 |
|
||||
| 决策 | 你实时判断 | 白名单+GLM 拟回复+代码闸门 |
|
||||
|
||||
两条路线共享同一套纪律(见 references/ops-workflow.md):失败基线优先、每步截图验证、
|
||||
安全闸门固化进代码、证据分级表述、守卫检查、三方同步。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Linux 云服务器路线:7x24 挂机的微信 Agent
|
||||
|
||||
> macOS 路线(vision.py)操作你眼前的电脑;本路线把微信搬上云服务器虚拟屏,
|
||||
> 配一个 GLM 视觉 Agent 常驻值守——人关机,微信不下线。已在 2核2G Ubuntu 24.04 实战。
|
||||
|
||||
## 与 macOS 路线的哲学分工
|
||||
|
||||
| | macOS(vision.py) | Linux 服务器(agent.py) |
|
||||
|---|---|---|
|
||||
| 坐标性质 | **易耗品**:每次实时定位,绝不落盘 | **固定资产**:虚拟屏分辨率固定+窗口最大化固定,坐标一次校准长期有效 |
|
||||
| 会话生命周期 | 跟随你的电脑 | 持久虚拟屏,断开连接微信照常在线 |
|
||||
| 决策 | 人(你)实时判断 | GLM 视觉模型+规则闸门 |
|
||||
| 适用 | 当下的一次性界面操作 | 长期自动值守 |
|
||||
|
||||
为什么 Linux 侧可以写死坐标:虚拟屏 `:1` 分辨率永远是 1440x900,微信窗口被 wmctrl
|
||||
强制最大化——布局漂移的根源(真实桌面)不存在。**一旦换了分辨率,全部坐标重新校准**,
|
||||
校准方法见下文。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
xfce4 桌面 + TigerVNC 虚拟屏 :1 (仅监听 localhost)
|
||||
└─ 微信官方 Linux 版(常驻)
|
||||
└─ agent.py (systemd 常驻)
|
||||
├─ 每 8 秒 scrot 截屏 → PIL 红点像素检测(本地,零成本)
|
||||
├─ 白名单过滤 → GLM 视觉读会话 → GLM 生成回复
|
||||
├─ 发送前视觉核验窗口标题(防发错人的最后闸门)
|
||||
└─ xdotool 点击 / xclip 剪贴板粘贴中文
|
||||
noVNC(websockify) ← SSH 隧道 ← 你的浏览器(看它干活)
|
||||
```
|
||||
|
||||
不 hook 协议、不逆向,纯 GUI 自动化,封号风险最低。
|
||||
|
||||
## 快速部署
|
||||
|
||||
```bash
|
||||
# 1. 环境搭建(幂等,装 xfce+TigerVNC+noVNC+中文字体+swap+微信,约10分钟)
|
||||
scp scripts/setup-server.sh root@<服务器IP>:/root/
|
||||
ssh root@<服务器IP> "bash setup-server.sh" # 末尾按提示 vncpasswd 设密码
|
||||
|
||||
# 2. 本机建隧道,浏览器开 http://localhost:6080/vnc.html 扫码登录
|
||||
ssh -L 6080:localhost:6080 root@<服务器IP>
|
||||
|
||||
# 3. 部署 Agent
|
||||
ssh root@<服务器IP> "mkdir -p /opt/wechat-agent"
|
||||
scp scripts/agent.py scripts/config-template.json root@<服务器IP>:/opt/wechat-agent/
|
||||
scp scripts/wechat-agent.service root@<服务器IP>:/etc/systemd/system/
|
||||
# 编辑 /opt/wechat-agent/config.json:填 bigmodel API key、改白名单
|
||||
ssh root@<服务器IP> "systemctl daemon-reload && systemctl enable --now wechat-agent"
|
||||
```
|
||||
|
||||
模型:bigmodel 免费档够用(`text: glm-4.5-flash` + `vision: glm-4v-flash`);
|
||||
付费档(glm-5.3-flash 等)需资源包,拿到 key 先逐模型探测再写配置。
|
||||
|
||||
## 三档模式(先 draft 后 auto)
|
||||
|
||||
- `observe`:只记录新消息到 inbox.log,零操作
|
||||
- `draft`:GLM 拟回复写 inbox.log,**不发送**——人工审一天看质量
|
||||
- `auto`:全自动发送
|
||||
|
||||
安全闸门(全部固化在代码里,不是散文):发送前视觉核验窗口标题(空标题一律拒绝)、
|
||||
白名单外零操作(不点击不标已读)、≤10条/时、23:00-7:00 静默、转账/密码类只回
|
||||
"记下来了"、`touch /opt/wechat-agent/PAUSE` 一秒急停。
|
||||
|
||||
## 坐标校准(换分辨率时)
|
||||
|
||||
1. `wmctrl -r Weixin -b add,maximized_vert,maximized_horz` 最大化
|
||||
2. `scrot` 截图,人工读图量出:搜索框、列表槽位(y0/间距)、图标列、输入框、标题栏
|
||||
3. 改 config.json 的 coords → `python3 agent.py once` 验证红点检测
|
||||
4. **永远先拿 File Transfer 当发送链路的小白鼠**,不拿真人会话试
|
||||
|
||||
## 运维速查
|
||||
|
||||
```bash
|
||||
tail -f /opt/wechat-agent/inbox.log # 消息流和草稿
|
||||
python3 /opt/wechat-agent/agent.py once # 单次检测
|
||||
python3 /opt/wechat-agent/agent.py send "联系人" "内容" # 定向发送(带核验)
|
||||
touch /opt/wechat-agent/PAUSE && rm /opt/wechat-agent/PAUSE # 暂停/恢复
|
||||
systemctl stop wechat-agent # 彻底停机
|
||||
```
|
||||
|
||||
## 已知坑
|
||||
|
||||
17 条实战踩坑实录见 [pitfalls-linux.md](pitfalls-linux.md),高频三则:
|
||||
xclip 会挂起 ssh 会话、pkill 模式会匹配脚本自身、微信窗口隐藏后 wmctrl 仍能看到它
|
||||
(恢复逻辑必须无条件激活,不能以"列表里没有"为条件)。
|
||||
|
||||
## 风险与边界
|
||||
|
||||
个人微信自动化违反微信用户协议,有封号风险:控制频率、建议小号先行、
|
||||
本仓库所有安全闸门不建议放宽。扫码前确认手机当前登录的是**目标账号**
|
||||
——桌面版可切换多账号,登错号是真实发生过的事故(见 pitfalls 第17条)。
|
||||
## 安全直连(替代 SSH 隧道的浏览器直达)
|
||||
|
||||
部署 nginx TLS+BasicAuth 反代:websockify 绑 127.0.0.1:6081,nginx 监听公网 6080 ssl,
|
||||
自签证书 + htpasswd(密码必须哈希,htpasswd -nb 生成)。访问 https://<IP>:6080/vnc.html
|
||||
(自签证书需手动信任一次)。安全组仅放行 6080;明文 HTTP 会被 TLS 端口直接拒绝(400)。
|
||||
配置样例见仓库 history v0.4.1。
|
||||
@@ -0,0 +1,74 @@
|
||||
# skill 建设与维护工作流(ops-workflow)
|
||||
|
||||
> 本仓库自身的维护纪律,方法论源:Loong Workflow
|
||||
> (provider-neutral corpus + drift guards),经实战校准。适用于本仓库以及你按本模式自建的一切 skill。
|
||||
|
||||
## 铁律:没有失败基线,就没有行为塑造型 skill
|
||||
|
||||
写 skill 就是给流程文档做 TDD:
|
||||
|
||||
1. **RED 基线**——先看真实失败。最好的人肉素材是你自己踩过的事故:逐字记录当时怎么错的、用了什么借口、什么压力触发的。禁止对着想象中的失败写规则。
|
||||
2. **GREEN 最小化**——只针对观察到的问题写内容。
|
||||
3. **REFACTOR**——实测发现新绕过方式,补条目,循环到堵住。
|
||||
|
||||
## 分类决定投入
|
||||
|
||||
| 类型 | 例 | 基线要求 |
|
||||
|---|---|---|
|
||||
| 行为塑造型(压力下必须守住闸门) | "发送前必须核验窗口标题" | 必须有失败基线 |
|
||||
| 技术/方法型 | 截图→识别→动作→验证 闭环 | 场景验证即可 |
|
||||
| 参考/机械型 | 命令速查、依赖清单 | 保真检查+守卫 |
|
||||
| 实战移植 | 本仓库的踩坑实录 | 对照事故记录保真 |
|
||||
|
||||
## 形式匹配失败类型(选错形式会适得其反)
|
||||
|
||||
| 基线失败 | 正确形式 | 错误形式 |
|
||||
|---|---|---|
|
||||
| 压力下违反规则 | 禁令+借口对照表+红旗清单 | 软 guidance("建议/尽量") |
|
||||
| 输出形状不对 | 配方:说清输出是什么、按什么顺序 | 禁令清单 |
|
||||
| 漏了必做步骤 | 模板里的必填槽位 | 模板旁边的散文提醒 |
|
||||
| 行为依赖条件 | 绑定可观察谓词的条件式 | 无条件规则+豁免条款 |
|
||||
|
||||
**关键推论:能固化进代码的闸门,绝不放在散文里。** 代码不会在截止日期压力下和你讨价还价——本仓库的"发送前核验标题"最终是 `agent.py` 里的 `header_matches()` 函数,不是 SKILL.md 里的一行字。
|
||||
|
||||
## 守卫(guards):漂移靠工具不靠记忆
|
||||
|
||||
skill 建好后会存在于多处(源目录/安装目录/部署实例/分发包),漂移是常态。`scripts/check-skill.sh` 检查:
|
||||
|
||||
结构完整、引用文件存在、凭证泄露(HARD)、私密信息(SOFT)、未证实断言、编码健康、脚本语法、CHANGELOG、分发包一致性。
|
||||
|
||||
```bash
|
||||
bash scripts/check-skill.sh <skill目录> [--bundle <tar.gz>]
|
||||
```
|
||||
|
||||
已踩过的守卫设计坑:守卫自身的检测模式会被当成命中(模式样本伪装命中)——扫描前剥离行内代码段、排除 guards/ 目录;规则文档里合法引用的违例词,靠"剥离代码段后重测"区分。
|
||||
|
||||
## 三方同步纪律
|
||||
|
||||
```
|
||||
源目录(唯一编辑点) → 安装目录 → 分发包(永远重新打包,禁止改包)
|
||||
部署实例(如服务器 /opt/xxx)的改动必须先回填源目录,再走发布
|
||||
```
|
||||
|
||||
记忆/文档只存指针(源在哪、包在哪、版本号),不复述内容——复述必然过时。
|
||||
|
||||
## 证据分级表述
|
||||
|
||||
写"实战验证"前先回答:证据是什么?
|
||||
|
||||
- ✅ "已验证:File Transfer 心跳链路端到端送达(截图确认)"
|
||||
- ✅ "未验证:私聊自动回复的实际送达(审计截图缺失,待人工确认)"
|
||||
- ❌ "已跑通,消息真实送达"(含糊,且当时并未眼见确认)
|
||||
|
||||
## 复盘(事故后必做,无指责)
|
||||
|
||||
DATA(每条失误附证据)→ ANAL(归因流程/工具/信息缺口,不归因个人)→ ACT(≤5条,每条有文件路径和完成判据)→ LOOP(素材文件真的改了)→ SAFE(不指责任何人,含自己)。
|
||||
|
||||
## 本仓库的应用实例
|
||||
|
||||
v0.2.0 整合 Linux 服务器 Agent 时,以上流程实际这样走:
|
||||
|
||||
1. **基线**:三天实战中的真实事故(私聊消息发错群、空标题核验漏洞、二维码被自愈循环刷新)就是 RED 数据
|
||||
2. **GREEN**:每条事故固化为 pitfalls 一条 + 可能的话固化为 agent.py 的闸门函数
|
||||
3. **守卫**:check-skill.sh 在发布前抓出分发模板里的真实联系人名和未证实断言(也抓出守卫自身的假阳性,遂加"剥离代码段"规则)
|
||||
4. **同步**:服务器 /opt/wechat-agent 的 agent.py 与仓库 scripts/agent.py 做了 diff 一致性确认
|
||||
@@ -0,0 +1,109 @@
|
||||
# 踩坑实录(全部实战验证过的解法)
|
||||
|
||||
## 1. xclip 会挂起整个 ssh 会话
|
||||
|
||||
`xclip -selection clipboard` 设置剪贴板后会**驻留为剪贴板所有者**,占住 ssh 的 stdout 管道,远程命令永不返回,还会导致后续 cat 截图文件拿到 0 字节。
|
||||
|
||||
**解法**:setsid 分离 + 三重丢弃:
|
||||
|
||||
```bash
|
||||
setsid xclip -selection clipboard -i /tmp/clip.txt >/dev/null 2>&1 < /dev/null &
|
||||
```
|
||||
|
||||
## 2. pkill 的模式匹配会杀掉脚本自己
|
||||
|
||||
远端脚本里写 `pkill -f "xclip -selection"`,如果脚本自己的命令行里也含这个字样(bash -c 整段就是命令行),**脚本会杀掉自己**,exit 255,后面的命令全不执行。括号技巧 `pkill -f "[x]clip"` 只对 grep/pkill 自身有效,**保护不了内嵌在脚本命令行里的字符串**。
|
||||
|
||||
**解法**:需要结束时用 `pkill -x xclip`(精确进程名);脚本里尽量不写 pkill。
|
||||
|
||||
## 3. 微信窗口被藏进托盘
|
||||
|
||||
点击搜索下拉、Esc、某些误操作会把窗口最小化到托盘,`wmctrl -l` 里看不到 Weixin,但进程活着。此时截屏全是桌面壁纸,Agent 变瞎,还会把壁纸当界面乱点。
|
||||
|
||||
**解法**:每轮轮询前 ensure_window():`wmctrl -l` 无 Weixin 就 `xdotool search --class wechat` 拿窗口 id,`wmctrl -i -a <id>` 拉回,再重新最大化。agent.py 已内置。
|
||||
|
||||
## 4. "Hide pinned chats" 折叠开关
|
||||
|
||||
微信聊天列表底部有折叠置顶的开关。**点击列表底部区域(y>826)极易误触它**,把置顶会话全部折叠——File Transfer 这类内置会话通常在置顶区,折叠后就"消失"了。折叠后列表顶部会出现 "N pinned chats" 字样。
|
||||
|
||||
**解法**:① 图标定位的 y 搜索上限设 826,永不进入开关区;② 发送前检测折叠条(vision 问 (60,240)-(300,285) 区域有无 pinned chats 字样),有就点 (160,260) 展开;③ 点图标用图标列中心 x=91,不用行中心。
|
||||
|
||||
## 5. File Transfer(文件传输助手)搜不到
|
||||
|
||||
英文 UI 下文件传输助手显示为 "File Transfer",且**搜索框的本地索引不收录它**——搜什么只会出网络搜索建议,点建议还会把窗口搞进托盘。
|
||||
|
||||
**解法**:用像素特征在列表里直接找:File Transfer 图标是绿圈+大白箭头,36px 高的窗口内 绿>250 且 白>250 双特征,取最底部匹配。绿 (1,194,96),判定 `r<80 and g>160 and 60<b<140`。agent.py 的 `find_green_icon_y` 已实现。
|
||||
|
||||
## 6. 中文输入只能走剪贴板
|
||||
|
||||
`xdotool type "中文"` 依赖键盘映射,输出的全是乱码。**唯一可靠**的是剪贴板+ctrl+v(见 SKILL.md 代码块)。注意粘贴后 sleep 0.6-1.5 再动手,大文本渲染需要时间。
|
||||
|
||||
## 7. 视觉模型读标题的两类幻觉
|
||||
|
||||
- 提示词问"输出标题文字",模型看不到时会**编造**或输出解释性长文("标题是微信。");
|
||||
- 解法:提示词里给"看不到就输出NONE"的出口,代码侧再兜底:`结果含NONE或长度>20 视为空`。agent.py 的 chat_header_name 已实现。
|
||||
|
||||
## 8. UI 是英文的
|
||||
|
||||
官方 Linux 微信跟随系统 locale,Ubuntu Server 默认英文 → 界面全是 "Search"/"File Transfer"/"Send"。给视觉模型的提示词要用英文界面词;联系人名以**界面显示名**为准(用户嘴里的"文件传输助手"=界面上的"File Transfer")。想要中文界面,在 xstartup 里 `export LANG=zh_CN.UTF-8` 并装 language-pack-zh-hans。
|
||||
|
||||
## 9. 发消息点进群聊(最严重事故)
|
||||
|
||||
坐标偏移 + 无核验 = 私聊消息发进 227 人群。除了发送前标题核验,还有两条纪律:
|
||||
|
||||
- 新坐标/新流程**永远先拿 File Transfer 做小白鼠**,不拿真人会话试;
|
||||
- 视觉定位返回的坐标,点击后必须再截屏核验"现在开的是不是目标会话",一步都不省。
|
||||
|
||||
## 10. 2G 内存配置基准
|
||||
|
||||
xfce(300M) + 微信(500M-1G) + Agent(PIL+requests 约 30M):available 常态 500M 左右。free -h 的 available < 200M 时先查 evolution 系列有没有复活,再考虑升配。evolution-alarm-notify 会在 xfce 会话每次重启时试图复活,Hidden=true 覆盖文件必须常驻。
|
||||
|
||||
## 11. systemd 服务里拉起的微信秒退(HOME 缺失)
|
||||
|
||||
systemd 服务环境默认**没有 HOME**。从 agent 进程里 Popen 启动微信,微信因写不了 `~/.xwechat` 立即退出,表现为"拉起后 ps 里没有进程"。解法:service 单元加 `Environment=HOME=/root`,且 agent.py 里 ENV 显式补 HOME。
|
||||
|
||||
## 12. wmctrl -lG 列序:`[3]` 是 y 不是宽度
|
||||
|
||||
`wmctrl -lG` 输出为 `id desktop x y W H name`,取宽度必须用 `split()[4]`。取错列会把 y=27(最大化窗口)当成"27px 的小窗口",整个状态机判断全歪。
|
||||
|
||||
## 13. 窗口未映射 ≠ 进程死亡(过渡态绝不能杀)
|
||||
|
||||
刚登录成功/启动中的微信,主窗口有几秒未映射期(wmctrl -lG 里没有 Weixin,但 pgrep 有进程)。如果把这当"进程死了"去 pkill+重启,**会杀掉刚登录的会话,用户被迫重新手机确认**。判据:窗口缺失时先 `pgrep -x wechat`,进程活着就只等待+激活,永远不要在进程存活时走硬重启。
|
||||
|
||||
## 14. 登录二维码窗的绿字 vs Log In 按钮都是微信绿
|
||||
|
||||
二维码登录窗上 "Scan to log in" 是绿色文字,和实心 Log In 按钮同色系。按"绿像素总数"判断会误触发点击。可靠判据:**单行连续绿像素数**——按钮 ~170x33 实心块,中心行采样约 75+;文字行零散笔画 ≤30。阈值取 50。
|
||||
|
||||
## 15. 任何自愈动作前先问:会不会毁掉登录态/二维码
|
||||
|
||||
微信进程一旦重启:登录退化为二维码,用户必须重新扫码+手机确认。所以自愈逻辑的每一版都要过一遍"最坏情况审查":误重启的代价是什么?亮度阈值、窗口宽判据、进程存活判断,宁可保守(等待)也不激进(重启)。重启冷却 90 秒是底线,不是保险。
|
||||
|
||||
## 16. 视觉模型会照抄提示词里的占位符
|
||||
|
||||
glm-4v-flash 对"只输出JSON: {chat_name:'名字', text:'...'}"这类带占位示例的提示词,可能**原样返回占位符**而不是读图(尤其图里没有聊天窗时)。对策:示例值用明显的假内容,代码侧校验(文本长度≥2 且不等于 "..."),不合规按读取失败处理。
|
||||
|
||||
## 17. 多账号陷阱:扫的码不一定是目标账号
|
||||
|
||||
桌面版微信记住了账号A,但登录页可 Switch Account;用户重新扫码时可能扫成**另一个账号B**。后果:聊天列表全变、白名单联系人"消失"(搜索只剩 Internet search results)。agent 表面正常,实际监护着错误的身份。
|
||||
|
||||
**对策**:①登录后第一件事,核对左上角头像/特征会话是否与白名单匹配;②白名单联系人在当前账号不存在时,搜索只剩网络建议——这是换错号的强信号;③部署时明确告知用户"扫码前确认手机当前登录的是哪个号"。
|
||||
|
||||
## 18. 搜索交互中按 Escape 会把渲染打成透明壳(会间接毁掉登录会话)
|
||||
|
||||
实测规律:在微信主窗口按 Escape(如"关掉残留下拉"的习惯动作)后,搜索交互会让
|
||||
Qt 渲染管线崩掉——窗口 Map State 仍是 IsViewable、wmctrl 一切正常,但内容全透明,
|
||||
截图只见壁纸。更糟的是:透明壳会被自愈逻辑判定"渲染死亡"而 pkill 重启,
|
||||
**刚登录的会话随之丢失**,用户被迫重新扫码+手机确认。
|
||||
|
||||
**对策**:①搜索路径禁用 Escape(进入搜索框后 ctrl+a 全选即可覆盖旧关键词);
|
||||
②自愈的透明壳重启前,先看进程存活时长——刚登录几分钟就"渲染死亡"的,
|
||||
大概率是这条坑,宁可等待观察也不要立即杀;
|
||||
③同样的坑没人知道微信哪版会修,升级微信后应重测。
|
||||
|
||||
## 19. 发文件时 Send 按钮点击无效(输入区必须先有焦点)
|
||||
|
||||
Qt 文件选择器的 File name 栏直接粘贴完整路径回车即可挂载文件,这步很稳;
|
||||
但挂载后点 Send 按钮若输入区从未获得过焦点,点击会被无声吞掉(连按两次都没用)。
|
||||
|
||||
**对策**:先点一下输入区任意位置(如文本光标处)让输入区拿到焦点,再点 Send。
|
||||
若仍未发出,补一个 Return(焦点在输入区时回车可发送文件卡片)。
|
||||
@@ -0,0 +1,915 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""微信值守 Agent:盯屏 → 识别新消息 → GLM-5.3-Flash 决策回复 → GUI 发送。
|
||||
模式: observe=只记录 | draft=拟回复不发送 | auto=自动发送(仅白名单)
|
||||
无 API key 时自动降级为 observe。触摸 /opt/wechat-agent/PAUSE 可暂停。
|
||||
"""
|
||||
import json, os, sys, time, base64, logging, subprocess, re
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
CFG = json.load(open(os.path.join(BASE, "config.json"), encoding="utf-8"))
|
||||
ENV = dict(os.environ, DISPLAY=CFG["display"], HOME=os.environ.get("HOME", "/root"))
|
||||
# HOME 必须有:systemd 服务环境默认不带 HOME,拉起的微信会因写不了 ~/.xwechat 秒退
|
||||
PAUSE = os.path.join(BASE, "PAUSE")
|
||||
INBOX = os.path.join(BASE, "inbox.log")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[logging.FileHandler(os.path.join(BASE, "agent.log"), encoding="utf-8"),
|
||||
logging.StreamHandler()])
|
||||
log = logging.getLogger("agent")
|
||||
|
||||
def sh(cmd, timeout=20):
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, timeout=timeout, env=ENV)
|
||||
|
||||
def scrot(path="/tmp/agent_shot.png"):
|
||||
sh(f"scrot -o {path}")
|
||||
return Image.open(path).convert("RGB")
|
||||
|
||||
LAST_RESTART = 0.0
|
||||
|
||||
def _weixin_width():
|
||||
out = sh("wmctrl -lG").stdout.decode(errors="ignore")
|
||||
for line in out.splitlines():
|
||||
if "Weixin" in line:
|
||||
try:
|
||||
return int(line.split()[4]) # lG 格式: id desk x y W H name
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
return 0
|
||||
|
||||
def _center_bright(img):
|
||||
"""窗口中心区亮度占比。亮=有内容渲染(主窗口或登录窗),暗=透明壳/无窗口"""
|
||||
region = img.crop((550, 300, 900, 620))
|
||||
n = tot = 0
|
||||
for px in region.getdata():
|
||||
r, g, b = px[:3]
|
||||
tot += 1
|
||||
if (r + g + b) / 3 > 110:
|
||||
n += 1
|
||||
return n / max(tot, 1)
|
||||
|
||||
def _login_button(img):
|
||||
"""登录窗的实心 Log In 按钮(~170x33px)。判据:单行连续绿像素>50。
|
||||
'Scan to log in'绿字每行只有零散笔画(≤30),实心按钮中心行约75+,可靠区分。"""
|
||||
px = img.load()
|
||||
max_cnt = 0
|
||||
for yy in range(470, 580, 2):
|
||||
cnt = 0
|
||||
for xx in range(620, 820, 2):
|
||||
r, g, b = px[xx, yy][:3]
|
||||
if r < 80 and g > 160 and 60 < b < 140:
|
||||
cnt += 1
|
||||
max_cnt = max(max_cnt, cnt)
|
||||
return max_cnt > 50
|
||||
|
||||
def _launch_app():
|
||||
"""硬重启:只用于进程死亡或窗口透明壳。进程活着但窗口未映射是登录/启动
|
||||
过渡态,绝不能走到这里——杀进程会毁掉刚登录的会话(实战踩坑)。"""
|
||||
global LAST_RESTART
|
||||
if time.time() - LAST_RESTART < 90: # 冷却,防止连环重启
|
||||
return False
|
||||
LAST_RESTART = time.time()
|
||||
log.warning("重启微信进程(渲染失效或进程消失)")
|
||||
sh("pkill -x wechat")
|
||||
time.sleep(3)
|
||||
subprocess.Popen("nohup /usr/bin/wechat >/dev/null 2>&1 &",
|
||||
shell=True, env=ENV,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
time.sleep(12)
|
||||
_activate_all()
|
||||
return True
|
||||
|
||||
def _activate_all():
|
||||
ids = sh("xdotool search --class wechat").stdout.decode().split()
|
||||
for line in ids:
|
||||
sh(f"wmctrl -i -a {hex(int(line))}") # 激活=自动取消最小化/托盘隐藏
|
||||
time.sleep(0.3)
|
||||
if "Weixin" in sh("wmctrl -l").stdout.decode(errors="ignore"):
|
||||
break
|
||||
|
||||
def ensure_window():
|
||||
"""确保微信主窗口可用。返回 True=主窗口就绪(已最大化)。
|
||||
自愈链:show-desktop清除 → 无条件激活 → 透明壳检测重启进程 → 登录窗自动点
|
||||
Log In → 手机确认期记日志等待。注意:微信隐藏到托盘后 wmctrl -l 仍列出它,
|
||||
不能用'列表里没有'当恢复条件。"""
|
||||
sh("wmctrl -k off")
|
||||
if not sh("xdotool search --class wechat").stdout.decode().split():
|
||||
_launch_app()
|
||||
_activate_all()
|
||||
for attempt in range(3):
|
||||
w = _weixin_width()
|
||||
img = scrot()
|
||||
bright = _center_bright(img)
|
||||
if w >= 700: # 主窗口(最大化或默认尺寸)
|
||||
if bright > 0.4:
|
||||
sh("wmctrl -r Weixin -b add,maximized_vert,maximized_horz")
|
||||
return True
|
||||
if not _launch_app(): # 透明壳:渲染死了,重启进程
|
||||
return False
|
||||
elif w > 0:
|
||||
# 小窗=登录/二维码/手机确认。只要还亮着就绝不重启进程——
|
||||
# 重启会刷新二维码,用户永远扫不上(实测踩坑)。
|
||||
if _login_button(img):
|
||||
log.info("检测到登录窗,自动点击 Log In")
|
||||
click(720, 540, 8.0)
|
||||
_activate_all()
|
||||
elif bright > 0.3:
|
||||
log_inbox("微信等待扫码或手机确认登录")
|
||||
log.warning("等待扫码/手机确认登录")
|
||||
return False
|
||||
elif not _launch_app():
|
||||
return False
|
||||
else:
|
||||
# 无窗口:进程活着=启动/登录过渡态,只等不杀(杀=毁掉登录会话)
|
||||
if sh("pgrep -x wechat").stdout.decode().split():
|
||||
log.info("微信窗口未映射,等待过渡完成")
|
||||
time.sleep(8)
|
||||
_activate_all()
|
||||
elif not _launch_app():
|
||||
return False
|
||||
return False
|
||||
|
||||
def click(x, y, wait=0.6):
|
||||
sh(f"xdotool mousemove {x} {y} click 1")
|
||||
time.sleep(wait)
|
||||
|
||||
def press(key, wait=0.5):
|
||||
sh(f"xdotool key {key}")
|
||||
time.sleep(wait)
|
||||
|
||||
def paste(text):
|
||||
with open("/tmp/agent_clip.txt", "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
subprocess.Popen("xclip -selection clipboard < /tmp/agent_clip.txt",
|
||||
shell=True, env=ENV,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
time.sleep(0.5)
|
||||
press("ctrl+v", 0.6)
|
||||
|
||||
def red_score(img, slot_i):
|
||||
"""某个聊天位号的红点像素数(新消息指示)"""
|
||||
c = CFG["coords"]
|
||||
y = c["slot_y0"] + slot_i * c["slot_dy"]
|
||||
box = (c["badge_x0"], y - 25, c["badge_x1"], y + 25)
|
||||
region = img.crop(box)
|
||||
n = 0
|
||||
for px in region.getdata():
|
||||
r, g, b = px[:3]
|
||||
if r > 195 and g < 95 and b < 95:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def list_region(img):
|
||||
c = CFG["coords"]
|
||||
return img.crop((c["list_x0"], c["list_y0"], c["list_x1"], c["list_y1"]))
|
||||
|
||||
def img_hash(img):
|
||||
small = img.resize((24, 64))
|
||||
return hash(tuple(v // 24 for p in small.getdata() for v in p[:3]))
|
||||
|
||||
def to_b64(img):
|
||||
img.save("/tmp/agent_b64.png")
|
||||
return base64.b64encode(open("/tmp/agent_b64.png", "rb").read()).decode()
|
||||
|
||||
# ---------------- GLM 大脑 ----------------
|
||||
def glm_chat(payload, timeout=90):
|
||||
import requests
|
||||
api = CFG["api"]
|
||||
r = requests.post(f"{api['base']}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api['key']}"},
|
||||
json={"model": payload.get("model", api["text_model"]),
|
||||
"messages": payload["messages"], "temperature": payload.get("temperature", 0.6)},
|
||||
timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
|
||||
def brain_ready():
|
||||
return bool(CFG["api"].get("key"))
|
||||
|
||||
def vision(prompt, img, model=None):
|
||||
return glm_chat({"model": model or CFG["api"]["vision_model"], "temperature": 0.2,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{to_b64(img)}"}},
|
||||
{"type": "text", "text": prompt}]}]})
|
||||
|
||||
def think(prompt):
|
||||
return glm_chat({"messages": [{"role": "user", "content": prompt}]})
|
||||
|
||||
# ---------------- 业务动作 ----------------
|
||||
def chat_header_name():
|
||||
"""读取聊天窗口顶部标题,用于发送前核验,防止发错人"""
|
||||
shot = scrot()
|
||||
try:
|
||||
ans = vision("这是微信聊天窗口顶部标题栏截图。如果里面显示了聊天对象的名字,只输出名字本身;"
|
||||
"看不到名字就只输出NONE。不要输出任何解释。",
|
||||
shot.crop((300, 60, 900, 105)))
|
||||
ans = ans.strip().strip('"')
|
||||
return "" if ("NONE" in ans.upper() or "微信" == ans or len(ans) > 20) else ans
|
||||
except Exception as e:
|
||||
log.warning("读标题失败: %s" % e)
|
||||
return ""
|
||||
|
||||
def header_matches(header, name):
|
||||
"""标题核验。header 为空一律不通过——空字符串是任何名字的子串,曾导致
|
||||
在登录界面也'核验通过'的漏洞。"""
|
||||
if not header:
|
||||
return False
|
||||
h, n = header.lower(), name.lower()
|
||||
return n in h or h in n
|
||||
|
||||
def open_chat_via_search(name):
|
||||
c = CFG["coords"]
|
||||
# 注意:此处绝不能按 Escape——实测按 Esc 会打断搜索交互并让渲染崩成透明壳,
|
||||
# 进而被自愈逻辑误判死亡而重启进程,毁掉登录会话(坑#18)。
|
||||
click(*c["search_box"])
|
||||
press("ctrl+a", 0.2)
|
||||
paste(name)
|
||||
time.sleep(1.6)
|
||||
shot = scrot()
|
||||
try:
|
||||
ans = vision("这是微信搜索下拉结果截图。找到「Contacts」区域下名字等于「%s」的联系人条目,"
|
||||
"返回中心坐标,格式 JSON: {\"x\":整数,\"y\":整数,\"found\":true/false}。"
|
||||
"坐标以截图左上角为原点,宽1440高900。没有本地联系人结果时 found=false。" % name, shot)
|
||||
m = re.search(r"\{[^{}]*\"x\"[^{}]*\}", ans, re.S)
|
||||
if m:
|
||||
pos = json.loads(m.group(0))
|
||||
if pos.get("found"):
|
||||
click(int(pos["x"]), int(pos["y"]), 1.5)
|
||||
header = chat_header_name()
|
||||
if header_matches(header, name):
|
||||
return True
|
||||
log.warning("打开的会话标题(%s)与目标(%s)不符,中止" % (header, name))
|
||||
return False
|
||||
except Exception as e:
|
||||
log.warning("视觉定位失败(%s),退回默认坐标" % e)
|
||||
click(*c["search_first_result"], 1.5) # 兜底:第一个结果位
|
||||
header = chat_header_name()
|
||||
ok = header_matches(header, name)
|
||||
if not ok:
|
||||
log.warning("兜底打开的会话标题(%s)与目标(%s)不符,中止" % (header, name))
|
||||
return ok
|
||||
|
||||
def read_conversation():
|
||||
shot = scrot()
|
||||
try:
|
||||
ans = vision("Read the WeChat chat window in this screenshot. Output JSON with the ACTUAL "
|
||||
"window title and the ACTUAL text of the most recent messages, transcribed "
|
||||
"character-for-character from the image. Never output placeholder text like "
|
||||
"... or generic words - only real content you can see. If the screenshot shows "
|
||||
"no chat window, output {\"chat_name\":\"\",\"messages\":[]}. "
|
||||
"Format: {\"chat_name\":\"<real title>\",\"messages\":[{\"from\":\"them|me\","
|
||||
"\"text\":\"<real message>\"}]} (newest last, max 6 messages).", shot)
|
||||
m = re.search(r"\{.*\}", ans, re.S)
|
||||
info = json.loads(m.group(0)) if m else None
|
||||
# 防幻觉:全是占位符/空文本视为读取失败
|
||||
if info and info.get("messages"):
|
||||
real = [x for x in info["messages"]
|
||||
if len(x.get("text", "")) >= 2 and x["text"] not in ("...", "…")]
|
||||
if not real:
|
||||
log.warning("视觉读取返回疑似占位符,丢弃")
|
||||
return None
|
||||
return info
|
||||
except Exception as e:
|
||||
log.warning("读会话失败: %s" % e)
|
||||
return None
|
||||
|
||||
def send_text(text):
|
||||
c = CFG["coords"]
|
||||
click(*c["input_box"])
|
||||
paste(text)
|
||||
press("Return", 0.8)
|
||||
if ensure_window(): # 发送动作可能触发窗口隐藏,救回来再审计
|
||||
time.sleep(0.5)
|
||||
scrot().save(os.path.join(BASE, "last_sent.png"))
|
||||
log.info("已发送: %s" % text[:40].replace("\n", " "))
|
||||
|
||||
SAFE_ACK = "这条我记下来了,回头我详细答复你"
|
||||
DANGER = re.compile(r"转账|红包|密码|验证码|汇款|付款")
|
||||
URL_RE = re.compile(r"https?://[^\s\"'<>]+")
|
||||
|
||||
def fetch_url_text(url):
|
||||
"""链接阅读(学自 WXAUTO_SE):抓网页正文,失败返回 None 走兜底"""
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
|
||||
if not r.encoding or r.encoding.lower() in ("iso-8859-1", "latin-1"):
|
||||
r.encoding = r.apparent_encoding # 同上,防中文乱码
|
||||
r.raise_for_status()
|
||||
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", r.text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
return re.sub(r"\s+", " ", text)[:3000]
|
||||
except Exception as e:
|
||||
log.warning("链接抓取失败 %s: %s" % (url, e))
|
||||
return None
|
||||
|
||||
def memory_file(chat):
|
||||
safe = re.sub(r"[^\w\u4e00-\u9fff-]", "_", chat) or "unknown"
|
||||
return os.path.join(BASE, "memory", safe + ".md")
|
||||
|
||||
def memory_context(chat):
|
||||
mf = memory_file(chat)
|
||||
if not os.path.exists(mf):
|
||||
return ""
|
||||
lines = open(mf, encoding="utf-8").readlines()
|
||||
return "".join(lines[-40:])
|
||||
|
||||
def remember_exchange(chat, last_them, reply):
|
||||
"""对话落记忆文件;每 10 次交互让 GLM 压缩一份摘要(学自 WXAUTO_SE 记忆功能)"""
|
||||
mf = memory_file(chat)
|
||||
os.makedirs(os.path.dirname(mf), exist_ok=True)
|
||||
stamp = datetime.now().strftime("%m-%d %H:%M")
|
||||
with open(mf, "a", encoding="utf-8") as f:
|
||||
f.write("[%s] 对方: %s\n[%s] 我: %s\n" % (
|
||||
stamp, (last_them or "")[:80], stamp, reply[:80].replace("\n", " ")))
|
||||
try:
|
||||
n = sum(1 for _ in open(mf, encoding="utf-8"))
|
||||
if n >= 20 and n % 20 < 2:
|
||||
head = "".join(open(mf, encoding="utf-8").readlines()[-40:])
|
||||
summary = think("把以下微信对话记录压缩成不超过300字的记忆要点(事实/承诺/偏好/待办),直接输出要点:\n" + head)
|
||||
sf = os.path.join(os.path.dirname(mf), "summary-" + os.path.basename(mf))
|
||||
open(sf, "w", encoding="utf-8").write(summary.strip())
|
||||
log.info("记忆摘要已更新: %s" % os.path.basename(sf))
|
||||
except Exception:
|
||||
log.warning("记忆压缩失败", exc_info=True)
|
||||
|
||||
KB_DIR = os.path.join(BASE, "kb")
|
||||
|
||||
def _ima_headers():
|
||||
ima = CFG.get("ima", {})
|
||||
return {"ima-openapi-clientid": ima.get("client_id", ""),
|
||||
"ima-openapi-apikey": ima.get("api_key", ""),
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
def ima_kb_id():
|
||||
"""解析知识库名→ID(内存缓存)。凭证只存服务器 config,不落日志不进仓库。"""
|
||||
ima = CFG.get("ima", {})
|
||||
if ima.get("kb_id"):
|
||||
return ima["kb_id"]
|
||||
if not (ima.get("enabled") and ima.get("client_id") and ima.get("api_key") and ima.get("kb_name")):
|
||||
return ""
|
||||
try:
|
||||
import requests
|
||||
r = requests.post("https://ima.qq.com/openapi/wiki/v1/search_knowledge_base",
|
||||
headers=_ima_headers(),
|
||||
json={"query": ima["kb_name"], "cursor": "", "limit": 10}, timeout=15)
|
||||
r.raise_for_status()
|
||||
for kb in (r.json().get("data") or {}).get("info_list") or []:
|
||||
# 实测 API 返回 kb_id/kb_name(文档写 id/name,不一致——兼容两者)
|
||||
name = kb.get("kb_name") or kb.get("name")
|
||||
kid = kb.get("kb_id") or kb.get("id")
|
||||
if name == ima["kb_name"] and kid:
|
||||
ima["kb_id"] = kid
|
||||
log.info("ima 知识库已解析: %s -> %s" % (name, kid))
|
||||
return kid
|
||||
log.warning("ima 未找到知识库: %s" % ima["kb_name"])
|
||||
except Exception as e:
|
||||
log.warning("ima 知识库解析失败: %s" % e)
|
||||
return ""
|
||||
|
||||
def _keywords(query):
|
||||
"""中文分词提关键词(jieba 优先),过滤虚词,按词长降序(信息量大的优先),最多6个"""
|
||||
words = []
|
||||
try:
|
||||
import jieba
|
||||
jieba.setLogLevel(logging.WARNING) # 静音 jieba 的建词典 DEBUG 输出
|
||||
words = [w for w in jieba.lcut(query) if len(w) >= 2]
|
||||
except Exception:
|
||||
words = re.findall(r"[A-Za-z0-9]{2,}", query)
|
||||
stop = {"怎么办", "怎么", "什么", "为什么", "可以", "应该", "如何", "一下", "看看",
|
||||
"介绍", "需要", "还是", "能把", "告诉", "我们", "你们", "知道", "出来",
|
||||
"相关", "信息", "没有", "这个", "那个", "时候", "开始", "内容", "资料",
|
||||
"文章", "重点", "整理"}
|
||||
seen, out = set(), []
|
||||
for w in sorted(set(words) - stop, key=len, reverse=True):
|
||||
if w not in seen:
|
||||
seen.add(w)
|
||||
out.append(w)
|
||||
# 中文前缀候选:长词的前两字也作为关键词(「四会开」→「四会」,专名常见形态)
|
||||
for w in list(out):
|
||||
if len(w) >= 3 and re.match(r"^[\u4e00-\u9fff]{2}", w):
|
||||
pre = w[:2]
|
||||
if pre not in seen:
|
||||
seen.add(pre)
|
||||
out.append(pre)
|
||||
return out[:6]
|
||||
|
||||
def ima_search(query):
|
||||
"""ima 开放平台 search_knowledge。长问题分词分别查,合并去重;
|
||||
返回 [{title, highlight, media_id}, ...] 最多6条。"""
|
||||
kb_id = ima_kb_id()
|
||||
if not kb_id:
|
||||
return []
|
||||
queries, seen_q = [], set()
|
||||
first = query.strip()[:20]
|
||||
if first:
|
||||
queries.append(first)
|
||||
for t in _keywords(query):
|
||||
if t not in seen_q:
|
||||
queries.append(t)
|
||||
out, seen = [], set()
|
||||
try:
|
||||
import requests
|
||||
for q in queries[:6]: # 控延迟:最多6次查询
|
||||
r = requests.post("https://ima.qq.com/openapi/wiki/v1/search_knowledge",
|
||||
headers=_ima_headers(),
|
||||
json={"query": q, "cursor": "", "knowledge_base_id": kb_id},
|
||||
timeout=15)
|
||||
r.raise_for_status()
|
||||
for x in ((r.json() or {}).get("data") or {}).get("info_list") or []:
|
||||
key = x.get("media_id") or x.get("title")
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({"title": x.get("title", ""),
|
||||
"highlight": x.get("highlight_content", ""),
|
||||
"media_id": x.get("media_id", "")})
|
||||
if len(out) >= 6:
|
||||
break
|
||||
except Exception as e:
|
||||
log.warning("ima 搜索失败: %s" % e)
|
||||
return out[:6]
|
||||
|
||||
def ima_media_text(media_id):
|
||||
"""网页型收藏取原文(get_media_info → url_info.url → 抓正文),失败返回 None"""
|
||||
try:
|
||||
import requests
|
||||
r = requests.post("https://ima.qq.com/openapi/wiki/v1/get_media_info",
|
||||
headers=_ima_headers(), json={"media_id": media_id}, timeout=15)
|
||||
url = (((r.json() or {}).get("data") or {}).get("url_info") or {}).get("url")
|
||||
if not url:
|
||||
return None
|
||||
rr = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
|
||||
if not rr.encoding or rr.encoding.lower() in ("iso-8859-1", "latin-1"):
|
||||
rr.encoding = rr.apparent_encoding # 防 UTF-8 被按 Latin-1 解成乱码
|
||||
rr.raise_for_status()
|
||||
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", rr.text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
return re.sub(r"\s+", " ", text)[:1500]
|
||||
except Exception as e:
|
||||
log.warning("ima 原文获取失败 %s: %s" % (media_id, e))
|
||||
return None
|
||||
|
||||
def kb_context(question):
|
||||
"""知识库检索(轻量RAG):扫 kb/ 下 md/txt 的段落,按问题关键词重合度取最优段。
|
||||
知识来源是 ima 知识库「AI教育杨老师」的导出文档;MCP 直连见 Loong 工单 #13。"""
|
||||
try:
|
||||
if not os.path.isdir(KB_DIR):
|
||||
return ""
|
||||
q = set(question)
|
||||
best, best_score = [], 0
|
||||
for fn in os.listdir(KB_DIR):
|
||||
if not fn.lower().endswith((".md", ".txt")):
|
||||
continue
|
||||
try:
|
||||
content = open(os.path.join(KB_DIR, fn), encoding="utf-8").read()
|
||||
except Exception:
|
||||
continue
|
||||
for para in re.split(r"\n\s*\n", content):
|
||||
para = para.strip()
|
||||
if len(para) < 30:
|
||||
continue
|
||||
score = sum(1 for ch in q if ch in para and not ch.isspace())
|
||||
if score > best_score and score >= 3:
|
||||
best, best_score = ["[%s] %s" % (fn, para[:600])], score
|
||||
elif score == best_score and score >= 3 and len(best) < 3:
|
||||
best.append("[%s] %s" % (fn, para[:600]))
|
||||
return ("\n\n".join(best))[:2500] if best else ""
|
||||
except Exception:
|
||||
log.warning("知识库检索失败", exc_info=True)
|
||||
return ""
|
||||
|
||||
def compose_reply(info):
|
||||
chat = info.get("chat_name", "未知")
|
||||
last_them = next((m["text"] for m in reversed(info.get("messages", []))
|
||||
if m["from"] == "them"), "")
|
||||
history = "\n".join(("对方: " if m["from"] == "them" else "我: ") + m["text"]
|
||||
for m in info["messages"])
|
||||
persona = CFG.get("personas", {}).get(chat, CFG["persona"]) # 每会话独立人设(学自 WXAUTO_SE)
|
||||
memory = memory_context(chat)
|
||||
link_note = ""
|
||||
urls = URL_RE.findall(last_them)
|
||||
if urls:
|
||||
content = fetch_url_text(urls[0])
|
||||
link_note = ("\n对方消息含链接 %s,网页正文如下:\n%s\n请结合网页内容回复。" % (urls[0], content)
|
||||
if content else
|
||||
"\n对方消息含链接 %s(抓取失败,可建议对方粘贴正文)。" % urls[0])
|
||||
prompt = (persona +
|
||||
"\n\n当前时间: " + datetime.now().strftime("%Y-%m-%d %H:%M %A") + # AI时间感知(学自 WXAUTO_SE)
|
||||
"\n当前聊天对象: " + chat +
|
||||
(("\n相关历史记忆:\n" + memory) if memory else "") +
|
||||
link_note +
|
||||
"\n最近对话:\n" + history +
|
||||
"\n\n请以「我」的身份回复对方最后一条消息。只输出回复正文,不要任何解释、引号或表情符号堆砌。")
|
||||
if chat in CFG.get("kb_chats", []):
|
||||
q = (last_them or history[-200:])
|
||||
kb_parts = []
|
||||
for h in ima_search(q)[:3]: # ima OpenAPI 优先
|
||||
line = "《%s》%s" % (h["title"], h["highlight"])
|
||||
if len(kb_parts) < 2 and h.get("media_id"): # 最多取2篇原文,控延迟
|
||||
text = ima_media_text(h["media_id"])
|
||||
if text:
|
||||
line += " 正文:" + text
|
||||
kb_parts.append(line)
|
||||
if not kb_parts:
|
||||
kb_parts.append(kb_context(q)) # 文件检索兜底
|
||||
kb = "\n".join(p for p in kb_parts if p)
|
||||
if kb:
|
||||
prompt += "\n\n知识库参考(「AI教育杨老师」知识库,回答以下列内容为准,没有的如实说需要查证):\n" + kb
|
||||
reply = think(prompt).strip().strip('"')
|
||||
if DANGER.search(reply):
|
||||
return SAFE_ACK
|
||||
remember_exchange(chat, last_them, reply)
|
||||
return reply
|
||||
|
||||
def in_quiet_hours():
|
||||
q = CFG.get("quiet_hours")
|
||||
if not q:
|
||||
return False
|
||||
h = datetime.now().hour
|
||||
a, b = q
|
||||
return h >= a or h < b
|
||||
|
||||
RATE = {"hour": None, "count": 0}
|
||||
SCHED_FILE = os.path.join(BASE, "scheduled.json")
|
||||
|
||||
def load_tasks():
|
||||
try:
|
||||
return json.load(open(SCHED_FILE, encoding="utf-8"))
|
||||
except Exception as e:
|
||||
if os.path.exists(SCHED_FILE):
|
||||
log.warning("load_tasks 解析失败: %s (内容前80字节: %r)" % (
|
||||
e, open(SCHED_FILE, "rb").read(80)))
|
||||
return []
|
||||
|
||||
def save_tasks(tasks):
|
||||
json.dump(tasks, open(SCHED_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
|
||||
def remove_task(task):
|
||||
"""手术式移除已发送的任务(按 run_at+message+chat 精确匹配),保留其他任务"""
|
||||
try:
|
||||
kept = [x for x in load_tasks()
|
||||
if not (x.get("run_at") == task.get("run_at")
|
||||
and x.get("message") == task.get("message")
|
||||
and x.get("chat") == task.get("chat"))]
|
||||
save_tasks(kept)
|
||||
except Exception:
|
||||
log.warning("任务移除失败", exc_info=True)
|
||||
|
||||
def run_due_tasks():
|
||||
"""到期定时任务经核验路径发送(学自 WXAUTO_SE 定时任务);daily 自动顺延。
|
||||
发送成功后手术式移除该条;失败重排队(+2分钟,3次放弃)。
|
||||
⚠️ 需 config scheduled_enabled=true;实测曾出现重复触发循环,修复前保持关闭。"""
|
||||
if not CFG.get("scheduled_enabled"):
|
||||
return
|
||||
now = datetime.now()
|
||||
tasks = load_tasks()
|
||||
if not tasks:
|
||||
return
|
||||
changed = False
|
||||
fired = 0
|
||||
for t in tasks:
|
||||
try:
|
||||
run_at = datetime.strptime(t["run_at"], "%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
continue
|
||||
if now < run_at:
|
||||
continue
|
||||
chat, msg = t.get("chat", ""), t.get("message", "")
|
||||
log.info("定时任务触发: %s -> %s" % (chat, msg[:30]))
|
||||
sent = False
|
||||
try:
|
||||
ensure_window()
|
||||
if open_chat_from_list(chat) or open_chat_via_search(chat):
|
||||
send_text(msg)
|
||||
log_inbox("定时任务已发送 %s: %s" % (chat, msg[:40]))
|
||||
sent = True
|
||||
fired += 1
|
||||
except Exception:
|
||||
log.exception("定时任务发送失败")
|
||||
if sent:
|
||||
if t.get("repeat") == "daily":
|
||||
t["run_at"] = (run_at + timedelta(days=1)).strftime("%Y-%m-%d %H:%M")
|
||||
changed = True
|
||||
else:
|
||||
remove_task(t)
|
||||
changed = True
|
||||
else:
|
||||
t["attempts"] = t.get("attempts", 0) + 1
|
||||
if t["attempts"] >= 3:
|
||||
log_inbox("定时任务连续3次失败已放弃: %s -> %s" % (chat, msg[:30]))
|
||||
remove_task(t)
|
||||
changed = True
|
||||
else:
|
||||
t["run_at"] = (now + timedelta(minutes=2)).strftime("%Y-%m-%d %H:%M")
|
||||
changed = True
|
||||
if changed:
|
||||
ensure_window()
|
||||
save_tasks(load_tasks()) # 重读后保存,合并并发改动
|
||||
|
||||
def maybe_schedule(text, chat):
|
||||
"""白名单消息含定时意图时 GLM 抽取结构化任务(学自 WXAUTO_SE);返回确认语或 None"""
|
||||
if not re.search(r"\d+\s*分钟|\d+\s*小时|每天.*点|明天.*点|提醒我", text):
|
||||
return None
|
||||
prompt = ("从这条微信消息抽取定时任务。只输出JSON:"
|
||||
'{"has": true/false, "run_at": "YYYY-MM-DD HH:MM", "message": "提醒内容", "repeat": "none或daily"}。'
|
||||
"没有定时意图则 has=false。当前时间: %s。消息: %s"
|
||||
% (datetime.now().strftime("%Y-%m-%d %H:%M"), text[:200]))
|
||||
try:
|
||||
m = re.search(r"\{[^{}]*\}", think(prompt), re.S)
|
||||
if not m:
|
||||
return None
|
||||
d = json.loads(m.group(0))
|
||||
if not d.get("has") or not d.get("run_at"):
|
||||
return None
|
||||
tasks = load_tasks()
|
||||
tasks.append({"run_at": d["run_at"], "chat": chat,
|
||||
"message": d.get("message", ""), "repeat": d.get("repeat", "none")})
|
||||
save_tasks(tasks)
|
||||
log.info("定时任务已登记: %s %s" % (d["run_at"], d.get("message", "")[:30]))
|
||||
return "好的,已定好:%s(%s)" % (d.get("message", "")[:30], d["run_at"])
|
||||
except Exception:
|
||||
log.warning("定时任务抽取失败", exc_info=True)
|
||||
return None
|
||||
|
||||
def group_needs_reply(chat_name, messages):
|
||||
"""群聊仅在被 @ 本号昵称时响应(学自 WXAUTO_SE)。群标题带成员数括号;
|
||||
未配置 self_nick 或非群会话保持原行为。"""
|
||||
if "(" not in (chat_name or ""):
|
||||
return True
|
||||
nick = CFG.get("self_nick", "")
|
||||
if not nick:
|
||||
return True
|
||||
them = " ".join(m.get("text", "") for m in messages if m.get("from") == "them")
|
||||
return nick in them
|
||||
|
||||
def rate_ok():
|
||||
h = datetime.now().hour
|
||||
if RATE["hour"] != h:
|
||||
RATE["hour"], RATE["count"] = h, 0
|
||||
return RATE["count"] < CFG["max_replies_per_hour"]
|
||||
|
||||
def log_inbox(line):
|
||||
with open(INBOX, "a", encoding="utf-8") as f:
|
||||
f.write("[%s] %s\n" % (datetime.now().strftime("%m-%d %H:%M:%S"), line))
|
||||
|
||||
def handle_trigger(slots, img):
|
||||
c = CFG["coords"]
|
||||
ensure_window()
|
||||
# 借视觉把槽位映射成联系人名(有大脑时)
|
||||
slot_map = {}
|
||||
if brain_ready():
|
||||
try:
|
||||
names = vision("这是微信聊天列表竖条截图。从上到下列出每个会话名字,"
|
||||
"只输出JSON数组:[{\"slot\":0,\"name\":\"...\"}]", list_region(img))
|
||||
m = re.search(r"\[.*\]", names, re.S)
|
||||
slot_map = {x["slot"]: x["name"] for x in json.loads(m.group(0))} if m else {}
|
||||
except Exception as e:
|
||||
log.warning("解析聊天列表失败: %s" % e)
|
||||
for s in slots:
|
||||
name = slot_map.get(s, "槽位%d" % s)
|
||||
log_inbox("新消息 来自 %s" % name)
|
||||
if not any(w and (w in name or name in w) for w in CFG["whitelist"]):
|
||||
log.info("跳过非白名单: %s" % name)
|
||||
continue
|
||||
if not brain_ready():
|
||||
log.info("观察模式,无大脑,不操作: %s" % name)
|
||||
continue
|
||||
# 消息合并去抖(学自 WXAUTO_SE):等突发的一串消息到齐再读,避免逐条回复
|
||||
time.sleep(CFG.get("merge_wait", 8))
|
||||
y = c["slot_y0"] + s * c["slot_dy"]
|
||||
click(c["slot_click_x"], y, 1.5) # 点开该会话
|
||||
info = read_conversation()
|
||||
if not info or not info.get("messages"):
|
||||
continue
|
||||
real_name = info.get("chat_name", "")
|
||||
if real_name and CFG.get("verify_chat_name", True) and \
|
||||
real_name not in name and name not in real_name:
|
||||
log.warning("窗口标题(%s)与预期(%s)不符,放弃回复" % (real_name, name))
|
||||
continue
|
||||
if not group_needs_reply(info.get("chat_name"), info.get("messages", [])):
|
||||
log.info("群内未被@本号,跳过: %s" % info.get("chat_name"))
|
||||
continue
|
||||
last_them = next((m["text"] for m in reversed(info.get("messages", []))
|
||||
if m["from"] == "them"), "")
|
||||
sched = maybe_schedule(last_them, info.get("chat_name") or "")
|
||||
if sched:
|
||||
send_text(sched)
|
||||
log_inbox("定时任务已设定: %s" % sched)
|
||||
continue
|
||||
if not rate_ok():
|
||||
log.warning("超时速率限制,跳过回复 %s" % name)
|
||||
continue
|
||||
reply = compose_reply(info)
|
||||
mode = CFG["mode"]
|
||||
if mode == "draft" or in_quiet_hours():
|
||||
log_inbox("草稿(%s%s): %s" % (name, " 静默时段" if in_quiet_hours() else "", reply))
|
||||
log.info("草稿模式,未发送")
|
||||
continue
|
||||
send_text(reply)
|
||||
RATE["count"] += 1
|
||||
log_inbox("已回复 %s: %s" % (name, reply))
|
||||
time.sleep(1)
|
||||
|
||||
def expand_pinned_if_collapsed(img):
|
||||
"""列表被折叠(显示 N pinned chats)时点开展开,否则找不到 File Transfer"""
|
||||
try:
|
||||
ans = vision("这是微信聊天列表顶部一小块截图。如果里面有「pinned chats」字样,"
|
||||
"只输出FOUND,否则只输出NO。", img.crop((60, 240, 300, 285)))
|
||||
if "FOUND" in ans.upper():
|
||||
click(160, 260, 1.0)
|
||||
time.sleep(0.8)
|
||||
log.info("已展开置顶聊天")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning("检查折叠条失败: %s" % e)
|
||||
return False
|
||||
|
||||
def _ft_icon_ref():
|
||||
return os.path.join(BASE, "ft_icon_ref.png")
|
||||
|
||||
def save_ft_template(img, y_top):
|
||||
"""标题核验成功后把该图标存为参考模板(自学习),供后续模板匹配择优"""
|
||||
try:
|
||||
img.crop((74, y_top, 110, y_top + 36)).save(_ft_icon_ref())
|
||||
log.info("FT 图标参考模板已更新")
|
||||
except Exception:
|
||||
log.warning("FT 模板保存失败", exc_info=True)
|
||||
|
||||
def find_green_icon_y(img):
|
||||
"""聊天列表图标列找 File Transfer 图标。候选=绿圈+白箭头双特征(green>250,white>250);
|
||||
存在参考模板时按 9x9 缩略图最小距离择优(修颜色质量误配,坑#2),无模板取最底部候选。"""
|
||||
c = CFG["coords"]
|
||||
px = img.load()
|
||||
cands = []
|
||||
y_cap = min(c["list_y1"] - 44, 826) # 避开底部 pinned chats 开关区
|
||||
for y in range(c["list_y0"], y_cap, 2):
|
||||
green = white = 0
|
||||
for yy in range(y, y + 36):
|
||||
for xx in range(74, 110):
|
||||
r, g, b = px[xx, yy][:3]
|
||||
if r < 80 and g > 160 and 60 < b < 140:
|
||||
green += 1
|
||||
elif r > 220 and g > 220 and b > 220:
|
||||
white += 1
|
||||
if green > 250 and white > 250:
|
||||
cands.append(y)
|
||||
if not cands:
|
||||
return None
|
||||
ref_path = _ft_icon_ref()
|
||||
if os.path.exists(ref_path):
|
||||
try:
|
||||
ref = Image.open(ref_path).convert("RGB").resize((9, 9))
|
||||
ref_data = list(ref.getdata())
|
||||
best, best_d = None, None
|
||||
for y in cands:
|
||||
for dy in (0, -2, 2): # 容忍1-2px行偏移
|
||||
yy = y + dy
|
||||
if yy < c["list_y0"] or yy + 36 > 870:
|
||||
continue
|
||||
patch = img.crop((74, yy, 110, yy + 36)).convert("RGB").resize((9, 9))
|
||||
d = sum((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2
|
||||
for a, b in zip(patch.getdata(), ref_data))
|
||||
if best_d is None or d < best_d:
|
||||
best, best_d = yy, d
|
||||
if best_d is None or best_d > 100000: # 校准:真身<10万(含微移),误配270万+
|
||||
log.warning("FT 模板匹配距离过大(%s),疑似列表异常,跳过" % best_d)
|
||||
return None
|
||||
log.info("FT 图标模板匹配: 候选%s 取 y=%s (距离%s)" % (cands[:5], best, best_d))
|
||||
return best + 18
|
||||
except Exception:
|
||||
log.warning("模板匹配异常,回退颜色规则", exc_info=True)
|
||||
return cands[-1] + 18
|
||||
|
||||
def open_chat_from_list(name):
|
||||
"""在聊天列表里用视觉找到目标会话并点开(适用于搜索索引搜不到的内置会话如 File Transfer)"""
|
||||
c = CFG["coords"]
|
||||
img = scrot()
|
||||
try:
|
||||
ans = vision("这是微信聊天列表截图。找到名字为「%s」的会话条目,返回它的头像中心坐标,"
|
||||
"格式 JSON: {\"x\":整数,\"y\":整数,\"found\":true/false}。"
|
||||
"坐标以图片左上角为原点,图片宽240高840。" % name, list_region(img))
|
||||
m = re.search(r"\{[^{}]*\"x\"[^{}]*\}", ans, re.S)
|
||||
if not m:
|
||||
return False
|
||||
pos = json.loads(m.group(0))
|
||||
if not pos.get("found"):
|
||||
log.warning("列表中未找到 %s" % name)
|
||||
return False
|
||||
click(int(pos["x"]) + c["list_x0"], int(pos["y"]) + c["list_y0"], 1.5)
|
||||
except Exception as e:
|
||||
log.warning("列表定位异常: %s" % e)
|
||||
return False
|
||||
header = chat_header_name()
|
||||
ok = header_matches(header, name)
|
||||
if not ok:
|
||||
log.warning("点开的会话标题(%s)与目标(%s)不符,中止" % (header, name))
|
||||
return ok
|
||||
|
||||
def heartbeat():
|
||||
hb = CFG.get("heartbeat_chat")
|
||||
if not hb or not brain_ready():
|
||||
return # 没有大脑时无法核验目标,不发,防误发
|
||||
hb_file = os.path.join(BASE, "last_hb")
|
||||
try:
|
||||
since = time.time() - os.path.getmtime(hb_file)
|
||||
except OSError:
|
||||
since = CFG["heartbeat_interval_min"] * 60
|
||||
if since < CFG["heartbeat_interval_min"] * 60 - 120:
|
||||
return # 未到期。上次心跳时间持久化到文件,
|
||||
# 服务重启不再重发(实测重启曾连发3条骚扰)
|
||||
if not ensure_window():
|
||||
return
|
||||
img = scrot()
|
||||
expand_pinned_if_collapsed(img)
|
||||
img = scrot()
|
||||
if hb.lower() in ("file transfer", "文件传输助手"):
|
||||
gy = find_green_icon_y(img)
|
||||
if gy is None:
|
||||
log.warning("未找到 File Transfer 绿色图标,跳过心跳")
|
||||
return
|
||||
click(91, gy, 2.0) # 点图标中心,避开下方折叠开关
|
||||
click_y_top = gy - 18
|
||||
elif not (open_chat_from_list(hb) or open_chat_via_search(hb)):
|
||||
click_y_top = None
|
||||
return
|
||||
header = chat_header_name()
|
||||
if hb.lower() not in header.lower():
|
||||
log.warning("心跳目标核验失败(标题:%s),不发送" % header)
|
||||
return
|
||||
try:
|
||||
if click_y_top is not None:
|
||||
# 必须用点击前的那张 img:点击后列表会重排(FT 跳到顶部),旧坐标处已不是 FT
|
||||
save_ft_template(img, click_y_top)
|
||||
except Exception:
|
||||
pass
|
||||
free = sh("free -m | awk '/Mem:/{print $7}'").stdout.decode().strip()
|
||||
send_text("〔自动心跳〕Agent在线 ✓ 可用内存%sMB 时间%s" % (free, datetime.now().strftime("%m-%d %H:%M")))
|
||||
open(hb_file, "w").write(datetime.now().isoformat())
|
||||
log.info("心跳已发")
|
||||
|
||||
# ---------------- 主循环 ----------------
|
||||
def main():
|
||||
ensure_window()
|
||||
prev = None
|
||||
red_state = {}
|
||||
log.info("Agent启动 mode=%s brain=%s" % (CFG["mode"], "GLM" if brain_ready() else "无(观察)"))
|
||||
while True:
|
||||
try:
|
||||
if os.path.exists(PAUSE):
|
||||
prev = None
|
||||
time.sleep(3)
|
||||
continue
|
||||
if CFG["mode"] == "observe" and not brain_ready():
|
||||
pass # 观察模式仍然盯屏记录
|
||||
if not ensure_window():
|
||||
prev = None # 未登录/渲染坏:不监控,等自愈
|
||||
time.sleep(8)
|
||||
continue
|
||||
img = scrot()
|
||||
cur = img_hash(list_region(img))
|
||||
if prev is not None and cur != prev:
|
||||
slots = [i for i in range(CFG["slot_count"]) if red_score(img, i) > CFG["red_threshold"]]
|
||||
fresh = [s for s in slots if red_state.get(s, 0) == 0]
|
||||
if fresh:
|
||||
log.info("检测到新消息槽位: %s" % fresh)
|
||||
handle_trigger(fresh, img)
|
||||
for s in slots:
|
||||
red_state[s] = 1
|
||||
for k in [s for s in red_state if s not in slots]:
|
||||
red_state[k] = 0
|
||||
prev = cur
|
||||
elif prev is None:
|
||||
prev = cur
|
||||
heartbeat() # 内部按 last_hb 文件到期判定,重启不重发
|
||||
run_due_tasks() # 定时任务到期即发
|
||||
except Exception:
|
||||
log.exception("循环异常")
|
||||
time.sleep(5)
|
||||
time.sleep(CFG["poll_interval"])
|
||||
|
||||
def cli_send(name, text):
|
||||
if not ensure_window():
|
||||
log.error("微信窗口未就绪(登录/渲染),未发送")
|
||||
return
|
||||
if not (open_chat_from_list(name) or open_chat_via_search(name)):
|
||||
log.error("无法核验目标会话 %s,未发送" % name)
|
||||
return
|
||||
send_text(text)
|
||||
log_inbox("CLI发送 %s: %s" % (name, text))
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 4 and sys.argv[1] == "send":
|
||||
cli_send(sys.argv[2], sys.argv[3])
|
||||
elif len(sys.argv) >= 2 and sys.argv[1] == "once":
|
||||
img = scrot(); ensure_window()
|
||||
slots = [i for i in range(CFG["slot_count"]) if red_score(img, i) > CFG["red_threshold"]]
|
||||
print("红点槽位:", slots)
|
||||
handle_trigger(slots, img)
|
||||
else:
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# skill-ops 守卫:检查一个 skill 目录的结构完整性与发布卫生
|
||||
# 用法: bash check-skill.sh <skill目录> [--bundle <tar.gz>]
|
||||
# 退出码: 0=通过 1=有违例
|
||||
set -uo pipefail
|
||||
|
||||
DIR="${1:-}"
|
||||
BUNDLE=""
|
||||
[ "${2:-}" = "--bundle" ] && BUNDLE="${3:-}"
|
||||
FAIL=0
|
||||
|
||||
err() { echo " ✗ $1"; FAIL=1; }
|
||||
ok() { echo " ✓ $1"; }
|
||||
|
||||
[ -z "$DIR" ] && { echo "用法: bash check-skill.sh <skill目录> [--bundle <tar.gz>]"; exit 1; }
|
||||
[ -d "$DIR" ] || { echo "目录不存在: $DIR"; exit 1; }
|
||||
DIR=$(cd "$DIR" && pwd)
|
||||
NAME=$(basename "$DIR")
|
||||
|
||||
echo "== 守卫: $NAME =="
|
||||
|
||||
# 1. SKILL.md 存在 + frontmatter
|
||||
S="$DIR/SKILL.md"
|
||||
if [ ! -f "$S" ]; then err "SKILL.md 不存在"; else
|
||||
ok "SKILL.md 存在"
|
||||
head -8 "$S" | grep -q "^name: *$NAME" && ok "name 与目录同名" || err "frontmatter name 应为: $NAME"
|
||||
D=$(sed -n 's/^description: *//p' "$S" | head -1)
|
||||
[ ${#D} -ge 20 ] && ok "description 非空(${#D}字符)" || err "description 缺失或过短"
|
||||
L=$(wc -l < "$S")
|
||||
[ "$L" -le 500 ] && ok "SKILL.md ${L} 行(≤500)" || err "SKILL.md ${L} 行,超 500,应拆分到 references/"
|
||||
fi
|
||||
|
||||
# 2. 引用的本地文件真实存在
|
||||
MISSING=$(grep -oE '(references|scripts|guards|checklists|rules)/[A-Za-z0-9._/-]+' "$S" 2>/dev/null | sort -u | while read -r f; do
|
||||
[ -e "$DIR/$f" ] || echo "$f"
|
||||
done)
|
||||
[ -z "$MISSING" ] && ok "SKILL.md 引用的文件全部存在" || { err "引用的文件不存在: $MISSING"; }
|
||||
|
||||
# 3. 凭证 HARD 扫描(guards/ 本身放检测模式,必须排除; 行内代码段先剥离——规则文档合法引用模式不算命中)
|
||||
VIOL=$(grep -rnE "sk-[A-Za-z0-9]{20,}|BEGIN.{0,10}PRIVATE KEY|AIBay666|c21aebeaf354" \
|
||||
--include="*.md" --include="*.json" --include="*.py" --include="*.sh" --include="*.service" \
|
||||
--exclude-dir="guards" "$DIR" 2>/dev/null | grep -vF "$(basename "$0")" | while IFS= read -r l; do
|
||||
echo "${l#*:}" | sed 's/`[^`]*`//g' | grep -qE "sk-[A-Za-z0-9]{20,}|BEGIN.{0,10}PRIVATE KEY|AIBay666|c21aebeaf354" && echo "$l"
|
||||
done | head -5)
|
||||
[ -z "$VIOL" ] && ok "无凭证泄露(HARD)" || { err "凭证泄露:"; echo "$VIOL" | sed 's/^/ /'; }
|
||||
|
||||
# 4. bigmodel key 形态扫描(hex.hex)
|
||||
VIOL2=$(grep -rnE "[a-f0-9]{32}\.[A-Za-z0-9]{12,20}" --include="*.json" --include="*.md" "$DIR" 2>/dev/null | grep -v '"key": ""' | head -3)
|
||||
[ -z "$VIOL2" ] && ok "无 bigmodel key 形态泄露" || { err "疑似 key:"; echo "$VIOL2" | sed 's/^/ /'; }
|
||||
|
||||
# 5. 私密信息 SOFT 扫描(分发内容不得含真实人名/具体联系人; rules 定义判据可含示例,排除)
|
||||
PII=$(grep -rnE "李娜纯臻|李娜\(|杨辉191|三级拆书" --include="*.md" --include="*.json" \
|
||||
--exclude-dir="guards" --exclude-dir="rules" --exclude-dir="checklists" "$DIR" 2>/dev/null | head -3)
|
||||
[ -z "$PII" ] && ok "无私密联系人信息(SOFT)" || { err "含私密信息(分发前匿名化):"; echo "$PII" | sed 's/^/ /'; }
|
||||
|
||||
# 6. 未证实断言扫描(剥离引号段——规则文档合法引用反例不算命中; 引号含中英文)
|
||||
CLAIM=$(grep -rn "真实送达\|已经跑通.*送达\|验证成功.*送达" "$DIR/SKILL.md" "$DIR/references" 2>/dev/null | while IFS= read -r l; do
|
||||
echo "${l#*:}" | sed -E 's/[“”][^“”]*[“”]//g; s/`[^`]*`//g; s/"[^"]*"//g' | grep -q "真实送达\|已经跑通.*送达\|验证成功.*送达" && echo "$l"
|
||||
done | head -3)
|
||||
[ -z "$CLAIM" ] && ok "无未证实的送达断言" || { err "含未证实断言:"; echo "$CLAIM" | sed 's/^/ /'; }
|
||||
|
||||
# 7. 编码健康(mojibake 特征)
|
||||
MOJI=$(grep -rl $'\xe9\x8d\x94\|\xe9\x94\x9f' --include="*.md" "$DIR" 2>/dev/null | head -3)
|
||||
[ -z "$MOJI" ] && ok "编码健康" || { err "疑似 mojibake: $MOJI"; }
|
||||
|
||||
# 8. 脚本语法
|
||||
for s in "$DIR"/scripts/*.sh; do
|
||||
[ -f "$s" ] || continue
|
||||
bash -n "$s" 2>/dev/null && ok "bash 语法: $(basename "$s")" || err "bash 语法错误: $(basename "$s")"
|
||||
done
|
||||
PY=$(command -v python3 || command -v python || true)
|
||||
for p in "$DIR"/scripts/*.py; do
|
||||
[ -f "$p" ] || continue
|
||||
if [ -n "$PY" ] && "$PY" -c "" >/dev/null 2>&1; then
|
||||
# Windows Store python 桩会假成功,交给远端校验;此处尽力而为
|
||||
"$PY" -m py_compile "$p" 2>/dev/null && ok "python 语法: $(basename "$p")" || echo " ⚠ python 语法未在本机验证: $(basename "$p") (无可用解释器时属预期)"
|
||||
else
|
||||
echo " ⚠ 本机无 python,跳过 $(basename "$p") 语法检查(可交远端校验)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 9. CHANGELOG
|
||||
[ -f "$DIR/CHANGELOG.md" ] && ok "CHANGELOG.md 存在" || err "缺 CHANGELOG.md(版本化要求)"
|
||||
|
||||
# 10. 分发包比对
|
||||
if [ -n "$BUNDLE" ]; then
|
||||
[ -f "$BUNDLE" ] || { err "分发包不存在: $BUNDLE"; }
|
||||
SRC=$(cd "$DIR/.." && tar -czf /tmp/_ref.tar.gz "$NAME" && tar -tzf /tmp/_ref.tar.gz | sort)
|
||||
PKG=$(tar -tzf "$BUNDLE" | sed 's#\\#/#g' | sort)
|
||||
DIFF=$(diff <(echo "$SRC") <(echo "$PKG") | grep -E "^[<>]" | head -10)
|
||||
[ -z "$DIFF" ] && ok "分发包与源目录一致" || { err "分发包漂移:"; echo "$DIFF" | sed 's/^/ /'; }
|
||||
rm -f /tmp/_ref.tar.gz
|
||||
fi
|
||||
|
||||
echo "== 结果: $([ $FAIL -eq 0 ] && echo 通过 || echo 存在违例) =="
|
||||
exit $FAIL
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"display": ":1",
|
||||
"poll_interval": 8,
|
||||
"merge_wait": 8,
|
||||
"mode": "draft",
|
||||
"whitelist": [
|
||||
"联系人A",
|
||||
"File Transfer"
|
||||
],
|
||||
"self_nick": "",
|
||||
"personas": {},
|
||||
"kb_chats": [],
|
||||
"ima": {
|
||||
"enabled": false,
|
||||
"client_id": "",
|
||||
"api_key": "",
|
||||
"kb_name": "",
|
||||
"kb_id": ""
|
||||
},
|
||||
"verify_chat_name": true,
|
||||
"max_replies_per_hour": 10,
|
||||
"quiet_hours": [
|
||||
23,
|
||||
7
|
||||
],
|
||||
"heartbeat_chat": "File Transfer",
|
||||
"heartbeat_interval_min": 360,
|
||||
"slot_count": 11,
|
||||
"red_threshold": 55,
|
||||
"coords": {
|
||||
"search_box": [
|
||||
159,
|
||||
84
|
||||
],
|
||||
"search_first_result": [
|
||||
200,
|
||||
175
|
||||
],
|
||||
"slot_y0": 137,
|
||||
"slot_dy": 69,
|
||||
"slot_click_x": 190,
|
||||
"badge_x0": 96,
|
||||
"badge_x1": 120,
|
||||
"list_x0": 60,
|
||||
"list_y0": 60,
|
||||
"list_x1": 300,
|
||||
"list_y1": 900,
|
||||
"input_box": [
|
||||
870,
|
||||
800
|
||||
]
|
||||
},
|
||||
"api": {
|
||||
"base": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"key": "",
|
||||
"vision_model": "glm-4v-flash",
|
||||
"text_model": "glm-4.5-flash"
|
||||
},
|
||||
"persona": "你是微信主人(按实际用户身份填写:职业、说话风格、常用语)的助理,代替他回复消息。风格:口语化、简短、像本人随手打的,1-2句为主,不用排比句和书面腔。涉及承诺时间、转账、隐私信息时只回:这条我记下来了,回头我详细答复你。"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# 微信 GUI Agent 一键环境搭建(Ubuntu 22.04/24.04 x86_64, 幂等可重复执行)
|
||||
# 用法: 上传到服务器 root 家目录, bash setup-server.sh
|
||||
# 人工步骤在脚本结尾打印(vncpasswd + 微信扫码)
|
||||
set -euo pipefail
|
||||
|
||||
echo "== 1/8 系统依赖 =="
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq xfce4 xfce4-goodies tigervnc-standalone-server tigervnc-common \
|
||||
novnc websockify xdotool xclip wmctrl scrot dbus-x11 \
|
||||
python3-pil python3-requests fonts-noto-cjk >/dev/null
|
||||
|
||||
echo "== 2/8 swap(存在则跳过) =="
|
||||
if ! swapon --show | grep -q /swapfile; then
|
||||
fallocate -l 4G /swapfile && chmod 600 /swapfile
|
||||
mkswap /swapfile && swapon /swapfile
|
||||
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
fi
|
||||
|
||||
echo "== 3/8 屏蔽 lightdm(xfce4-goodies 连带装上的,防开机抢占内存) =="
|
||||
systemctl disable --now lightdm >/dev/null 2>&1 || true
|
||||
|
||||
echo "== 4/8 屏蔽 Evolution 自启(省约100M内存) =="
|
||||
mkdir -p ~/.config/autostart
|
||||
for app in org.gnome.Evolution-alarm-notify print-applet; do
|
||||
printf '[Desktop Entry]\nType=Application\nHidden=true\n' > ~/.config/autostart/$app.desktop
|
||||
done
|
||||
pkill -f "[e]volution" 2>/dev/null || true
|
||||
|
||||
echo "== 5/8 VNC xstartup =="
|
||||
mkdir -p ~/.vnc
|
||||
cat > ~/.vnc/xstartup <<'EOF'
|
||||
#!/bin/sh
|
||||
unset SESSION_MANAGER
|
||||
unset DBUS_SESSION_BUS_ADDRESS
|
||||
exec startxfce4
|
||||
EOF
|
||||
chmod +x ~/.vnc/xstartup
|
||||
|
||||
echo "== 6/8 VNC systemd 服务(虚拟屏 :1, 只听 localhost) =="
|
||||
cat > /etc/systemd/system/tigervncserver@:1.service <<EOF
|
||||
[Unit]
|
||||
Description=Remote desktop service (VNC :1)
|
||||
After=syslog.target network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=root
|
||||
ExecStartPre=/usr/bin/tigervncserver -kill :1 >/dev/null 2>&1 || true
|
||||
ExecStart=/usr/bin/tigervncserver -localhost=1 -geometry 1440x900 -depth 24 :1
|
||||
ExecStop=/usr/bin/tigervncserver -kill :1
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "== 7/8 noVNC systemd 服务(浏览器访问, 只听 localhost:6080) =="
|
||||
cat > /etc/systemd/system/novnc.service <<EOF
|
||||
[Unit]
|
||||
Description=noVNC browser VNC client (localhost only)
|
||||
After=network.target tigervncserver@:1.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/websockify --web=/usr/share/novnc 127.0.0.1:6080 localhost:5901
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "== 8/8 下载安装微信官方 Linux 版 =="
|
||||
if ! command -v wechat >/dev/null; then
|
||||
curl -sL --retry 3 -o /tmp/WeChatLinux.deb \
|
||||
"https://dldir1v6.qq.com/weixin/Universal/Linux/WeChatLinux_x86_64.deb"
|
||||
apt-get install -y -qq /tmp/WeChatLinux.deb >/dev/null
|
||||
rm -f /tmp/WeChatLinux.deb
|
||||
fi
|
||||
|
||||
cat <<'NOTE'
|
||||
|
||||
========== 人工步骤 ==========
|
||||
1) 设 VNC 密码(记牢,浏览器连接时用): vncpasswd
|
||||
2) 启动桌面和远程访问:
|
||||
systemctl enable --now tigervncserver@:1 novnc
|
||||
3) 本机建隧道后浏览器打开 http://localhost:6080/vnc.html 扫码登录微信:
|
||||
ssh -L 6080:localhost:6080 root@<服务器IP>
|
||||
4) 部署 Agent(见 SKILL.md 快速开始第3步)
|
||||
NOTE
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=WeChat GUI Agent (GLM brain)
|
||||
After=tigervncserver@:1.service
|
||||
Wants=tigervncserver@:1.service
|
||||
|
||||
[Service]
|
||||
Environment=DISPLAY=:1
|
||||
Environment=HOME=/root
|
||||
WorkingDirectory=/opt/wechat-agent
|
||||
ExecStart=/usr/bin/python3 /opt/wechat-agent/agent.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,33 @@
|
||||
# 本仓库运行制度:Loong Workflow 实例
|
||||
|
||||
> 本项目的一切变更走工单迭代。制度源:Loong Workflow(语料库部署于 Loong Gitea)。
|
||||
> 工单板:https://eightarms.net/kenyang/wechat-ops/issues
|
||||
|
||||
## 路由表(什么改动走什么流程)
|
||||
|
||||
| 改动类型 | 流程 | 关单条件 |
|
||||
|---|---|---|
|
||||
| 新功能(feat) | 工单 → 实现 → 服务器实测验证 → 证据评论 → 关闭 | 验收标准全过 + 眼见确认 |
|
||||
| bug 修复 | 工单(附复现证据) → 修复 → 回归测试 → 关闭 | 修复前失败/修复后通过的实测记录 |
|
||||
| 文档 | 直接改 → 守卫 → 推送 | — |
|
||||
| 小发现/技术债 | **TD 登记册**(#1)加登记行,不单开工单 | 认领时才升票 |
|
||||
|
||||
## 工单约定(继承 Loong Workflow)
|
||||
|
||||
- 父子工单双向链接:子工单正文带 `## Parent`
|
||||
- 技术债先登记(#1 登记册,TD-NNN 行),认领升票
|
||||
- 关单即归档:证据评论里写清 实测时间/结果/日志位置
|
||||
- 诚实红线:没有眼见确认不写"已验证";错误关票要发状态修正评论(实例:2026-09-29 #6/#7/#8/#9 误关纠正)
|
||||
|
||||
## 发布流程
|
||||
|
||||
1. 本地/服务器改 agent.py → 服务器 py_compile → 部署实测
|
||||
2. 回填本仓库 scripts/ → CHANGELOG 版本条目
|
||||
3. 双远端推送:origin(GitHub) + loong(Loong Gitea)
|
||||
4. 重建分发包 wechat-gui-agent-skill.tar.gz(OneDrive zcode)
|
||||
5. 跑 scripts/check-skill.sh 守卫(如涉及 skill 结构)
|
||||
|
||||
## 当前状态看板
|
||||
|
||||
- 迭代1(v0.3.x-v0.4.0):已发布。#2 #4 #5 #6(实验性,开关关闭) #7 #9 #11 #12 #13 已关闭
|
||||
- 开放中:#3(送达确认,需杨辉号) #8(表情包) #10(6080安全直连) #1(TD登记册,4行)
|
||||
Reference in New Issue
Block a user