diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9e4d967 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# 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(坐标绝不落盘、每步验证、朋友圈发布全流程案例)。 diff --git a/README.md b/README.md index 4368164..d971c02 100644 --- a/README.md +++ b/README.md @@ -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 时,服务器部署实例必须回填仓库后再发布。 diff --git a/SKILL.md b/SKILL.md index 73d9cff..d8479f8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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):失败基线优先、每步截图验证、 +安全闸门固化进代码、证据分级表述、守卫检查、三方同步。 + +--- ## 一、核心原则 diff --git a/references/linux-server.md b/references/linux-server.md new file mode 100644 index 0000000..1e17922 --- /dev/null +++ b/references/linux-server.md @@ -0,0 +1,92 @@ +# 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条)。 diff --git a/references/ops-workflow.md b/references/ops-workflow.md new file mode 100644 index 0000000..da1e380 --- /dev/null +++ b/references/ops-workflow.md @@ -0,0 +1,74 @@ +# skill 建设与维护工作流(ops-workflow) + +> 本仓库自身的维护纪律,方法论源:[octopus-workflow](https://eightarms.net/Octopus/octopus-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 [--bundle ] +``` + +已踩过的守卫设计坑:守卫自身的检测模式会被当成命中(模式样本伪装命中)——扫描前剥离行内代码段、排除 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 一致性确认 diff --git a/references/pitfalls-linux.md b/references/pitfalls-linux.md new file mode 100644 index 0000000..3141151 --- /dev/null +++ b/references/pitfalls-linux.md @@ -0,0 +1,89 @@ +# 踩坑实录(全部实战验证过的解法) + +## 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 ` 拉回,再重新最大化。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 6020 视为空`。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 表面正常,实际监护着错误的身份。 + +**对策**:①登录后第一件事,核对左上角头像/特征会话是否与白名单匹配;②白名单联系人在当前账号不存在时,搜索只剩网络建议——这是换错号的强信号;③部署时明确告知用户"扫码前确认手机当前登录的是哪个号"。 diff --git a/scripts/agent.py b/scripts/agent.py new file mode 100644 index 0000000..5f3c936 --- /dev/null +++ b/scripts/agent.py @@ -0,0 +1,515 @@ +#!/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 +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"] + press("Escape", 0.3) # 关掉可能残留的下拉 + 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\":\"\",\"messages\":[{\"from\":\"them|me\"," + "\"text\":\"\"}]} (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"转账|红包|密码|验证码|汇款|付款") + +def compose_reply(info): + history = "\n".join(("对方: " if m["from"] == "them" else "我: ") + m["text"] + for m in info["messages"]) + prompt = (CFG["persona"] + + "\n\n当前聊天对象: " + info.get("chat_name", "未知") + + "\n最近对话:\n" + history + + "\n\n请以「我」的身份回复对方最后一条消息。只输出回复正文,不要任何解释、引号或表情符号堆砌。") + reply = think(prompt).strip().strip('"') + if DANGER.search(reply): + return SAFE_ACK + 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} +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 name not in CFG["whitelist"]: + log.info("跳过非白名单: %s" % name) + continue + if not brain_ready(): + log.info("观察模式,无大脑,不操作: %s" % name) + continue + 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 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 find_green_icon_y(img): + """聊天列表图标列里找 File Transfer 图标:绿圈+大白箭头双特征,取最底部的匹配""" + c = CFG["coords"] + px = img.load() + best = None + 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: + best = y + 18 + return best + +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 # 没有大脑时无法核验目标,不发,防误发 + 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) # 点图标中心,避开下方折叠开关 + elif not (open_chat_from_list(hb) or open_chat_via_search(hb)): + return + header = chat_header_name() + if hb.lower() not in header.lower(): + log.warning("心跳目标核验失败(标题:%s),不发送" % header) + return + free = sh("free -m | awk '/Mem:/{print $7}'").stdout.decode().strip() + send_text("〔自动心跳〕Agent在线 ✓ 可用内存%sMB 时间%s" % (free, datetime.now().strftime("%m-%d %H:%M"))) + log.info("心跳已发") + +# ---------------- 主循环 ---------------- +def main(): + ensure_window() + prev = None + red_state = {} + last_hb = time.time() - CFG["heartbeat_interval_min"] * 60 + 90 # 启动90秒后先发一次心跳 + 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 s in [k for k in red_state if k not in slots]: + red_state[k] = 0 + prev = cur + elif prev is None: + prev = cur + if time.time() - last_hb > CFG["heartbeat_interval_min"] * 60: + heartbeat() + last_hb = time.time() + 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() diff --git a/scripts/check-skill.sh b/scripts/check-skill.sh new file mode 100644 index 0000000..ca51e02 --- /dev/null +++ b/scripts/check-skill.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# skill-ops 守卫:检查一个 skill 目录的结构完整性与发布卫生 +# 用法: bash check-skill.sh [--bundle ] +# 退出码: 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 [--bundle ]"; 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 diff --git a/scripts/config-template.json b/scripts/config-template.json new file mode 100644 index 0000000..fdbf1e5 --- /dev/null +++ b/scripts/config-template.json @@ -0,0 +1,49 @@ +{ + "display": ":1", + "poll_interval": 8, + "mode": "draft", + "whitelist": [ + "联系人A", + "File Transfer" + ], + "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句为主,不用排比句和书面腔。涉及承诺时间、转账、隐私信息时只回:这条我记下来了,回头我详细答复你。" +} \ No newline at end of file diff --git a/scripts/setup-server.sh b/scripts/setup-server.sh new file mode 100644 index 0000000..77b9cc0 --- /dev/null +++ b/scripts/setup-server.sh @@ -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 </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 </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 diff --git a/scripts/wechat-agent.service b/scripts/wechat-agent.service new file mode 100644 index 0000000..de637fe --- /dev/null +++ b/scripts/wechat-agent.service @@ -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