Compare commits

...
8 Commits
5 changed files with 482 additions and 8 deletions
+35
View File
@@ -22,3 +22,38 @@ macOS 视觉自动化技能初版:vision.py + 单文件 SKILL.md(坐标绝不落
- agent.py: 消息合并去抖(merge_wait)、AI时间感知(当前时间注入prompt)、心跳时间持久化(重启不重发)、修复 Python3 推导式变量泄漏导致的红点状态复位崩溃 - agent.py: 消息合并去抖(merge_wait)、AI时间感知(当前时间注入prompt)、心跳时间持久化(重启不重发)、修复 Python3 推导式变量泄漏导致的红点状态复位崩溃
- pitfalls-linux.md: 第19条(发文件需先聚焦输入区,否则 Send 点击被吞) - pitfalls-linux.md: 第19条(发文件需先聚焦输入区,否则 Send 点击被吞)
- config-template: 新增 merge_wait - 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 四会会议笔记
+6
View File
@@ -90,3 +90,9 @@ xclip 会挂起 ssh 会话、pkill 模式会匹配脚本自身、微信窗口隐
个人微信自动化违反微信用户协议,有封号风险:控制频率、建议小号先行、 个人微信自动化违反微信用户协议,有封号风险:控制频率、建议小号先行、
本仓库所有安全闸门不建议放宽。扫码前确认手机当前登录的是**目标账号** 本仓库所有安全闸门不建议放宽。扫码前确认手机当前登录的是**目标账号**
——桌面版可切换多账号,登错号是真实发生过的事故(见 pitfalls 第17条)。 ——桌面版可切换多账号,登错号是真实发生过的事故(见 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。
+398 -8
View File
@@ -5,7 +5,7 @@
无 API key 时自动降级为 observe。触摸 /opt/wechat-agent/PAUSE 可暂停。 无 API key 时自动降级为 observe。触摸 /opt/wechat-agent/PAUSE 可暂停。
""" """
import json, os, sys, time, base64, logging, subprocess, re import json, os, sys, time, base64, logging, subprocess, re
from datetime import datetime from datetime import datetime, timedelta
from PIL import Image from PIL import Image
BASE = os.path.dirname(os.path.abspath(__file__)) BASE = os.path.dirname(os.path.abspath(__file__))
@@ -291,18 +291,240 @@ def send_text(text):
SAFE_ACK = "这条我记下来了,回头我详细答复你" SAFE_ACK = "这条我记下来了,回头我详细答复你"
DANGER = re.compile(r"转账|红包|密码|验证码|汇款|付款") 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): 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"] history = "\n".join(("对方: " if m["from"] == "them" else "我: ") + m["text"]
for m in info["messages"]) for m in info["messages"])
prompt = (CFG["persona"] + 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\n当前时间: " + datetime.now().strftime("%Y-%m-%d %H:%M %A") + # AI时间感知(学自 WXAUTO_SE)
"\n当前聊天对象: " + info.get("chat_name", "未知") + "\n当前聊天对象: " + chat +
(("\n相关历史记忆:\n" + memory) if memory else "") +
link_note +
"\n最近对话:\n" + history + "\n最近对话:\n" + history +
"\n\n请以「我」的身份回复对方最后一条消息。只输出回复正文,不要任何解释、引号或表情符号堆砌。") "\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('"') reply = think(prompt).strip().strip('"')
if DANGER.search(reply): if DANGER.search(reply):
return SAFE_ACK return SAFE_ACK
remember_exchange(chat, last_them, reply)
return reply return reply
def in_quiet_hours(): def in_quiet_hours():
@@ -314,6 +536,118 @@ def in_quiet_hours():
return h >= a or h < b return h >= a or h < b
RATE = {"hour": None, "count": 0} 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(): def rate_ok():
h = datetime.now().hour h = datetime.now().hour
if RATE["hour"] != h: if RATE["hour"] != h:
@@ -340,7 +674,7 @@ def handle_trigger(slots, img):
for s in slots: for s in slots:
name = slot_map.get(s, "槽位%d" % s) name = slot_map.get(s, "槽位%d" % s)
log_inbox("新消息 来自 %s" % name) log_inbox("新消息 来自 %s" % name)
if name not in CFG["whitelist"]: if not any(w and (w in name or name in w) for w in CFG["whitelist"]):
log.info("跳过非白名单: %s" % name) log.info("跳过非白名单: %s" % name)
continue continue
if not brain_ready(): if not brain_ready():
@@ -358,6 +692,16 @@ def handle_trigger(slots, img):
real_name not in name and name not in real_name: real_name not in name and name not in real_name:
log.warning("窗口标题(%s)与预期(%s)不符,放弃回复" % (real_name, name)) log.warning("窗口标题(%s)与预期(%s)不符,放弃回复" % (real_name, name))
continue 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(): if not rate_ok():
log.warning("超时速率限制,跳过回复 %s" % name) log.warning("超时速率限制,跳过回复 %s" % name)
continue continue
@@ -386,11 +730,23 @@ def expand_pinned_if_collapsed(img):
log.warning("检查折叠条失败: %s" % e) log.warning("检查折叠条失败: %s" % e)
return False 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): def find_green_icon_y(img):
"""聊天列表图标列里找 File Transfer 图标:绿圈+大白箭头双特征,取最底部的匹配""" """聊天列表图标列找 File Transfer 图标。候选=绿圈+白箭头双特征(green>250,white>250);
存在参考模板时按 9x9 缩略图最小距离择优(修颜色质量误配,坑#2),无模板取最底部候选。"""
c = CFG["coords"] c = CFG["coords"]
px = img.load() px = img.load()
best = None cands = []
y_cap = min(c["list_y1"] - 44, 826) # 避开底部 pinned chats 开关区 y_cap = min(c["list_y1"] - 44, 826) # 避开底部 pinned chats 开关区
for y in range(c["list_y0"], y_cap, 2): for y in range(c["list_y0"], y_cap, 2):
green = white = 0 green = white = 0
@@ -402,8 +758,33 @@ def find_green_icon_y(img):
elif r > 220 and g > 220 and b > 220: elif r > 220 and g > 220 and b > 220:
white += 1 white += 1
if green > 250 and white > 250: if green > 250 and white > 250:
best = y + 18 cands.append(y)
return best 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): def open_chat_from_list(name):
"""在聊天列表里用视觉找到目标会话并点开(适用于搜索索引搜不到的内置会话如 File Transfer)""" """在聊天列表里用视觉找到目标会话并点开(适用于搜索索引搜不到的内置会话如 File Transfer)"""
@@ -453,12 +834,20 @@ def heartbeat():
log.warning("未找到 File Transfer 绿色图标,跳过心跳") log.warning("未找到 File Transfer 绿色图标,跳过心跳")
return return
click(91, gy, 2.0) # 点图标中心,避开下方折叠开关 click(91, gy, 2.0) # 点图标中心,避开下方折叠开关
click_y_top = gy - 18
elif not (open_chat_from_list(hb) or open_chat_via_search(hb)): elif not (open_chat_from_list(hb) or open_chat_via_search(hb)):
click_y_top = None
return return
header = chat_header_name() header = chat_header_name()
if hb.lower() not in header.lower(): if hb.lower() not in header.lower():
log.warning("心跳目标核验失败(标题:%s),不发送" % header) log.warning("心跳目标核验失败(标题:%s),不发送" % header)
return 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() free = sh("free -m | awk '/Mem:/{print $7}'").stdout.decode().strip()
send_text("〔自动心跳〕Agent在线 ✓ 可用内存%sMB 时间%s" % (free, datetime.now().strftime("%m-%d %H:%M"))) send_text("〔自动心跳〕Agent在线 ✓ 可用内存%sMB 时间%s" % (free, datetime.now().strftime("%m-%d %H:%M")))
open(hb_file, "w").write(datetime.now().isoformat()) open(hb_file, "w").write(datetime.now().isoformat())
@@ -498,6 +887,7 @@ def main():
elif prev is None: elif prev is None:
prev = cur prev = cur
heartbeat() # 内部按 last_hb 文件到期判定,重启不重发 heartbeat() # 内部按 last_hb 文件到期判定,重启不重发
run_due_tasks() # 定时任务到期即发
except Exception: except Exception:
log.exception("循环异常") log.exception("循环异常")
time.sleep(5) time.sleep(5)
+10
View File
@@ -7,6 +7,16 @@
"联系人A", "联系人A",
"File Transfer" "File Transfer"
], ],
"self_nick": "",
"personas": {},
"kb_chats": [],
"ima": {
"enabled": false,
"client_id": "",
"api_key": "",
"kb_name": "",
"kb_id": ""
},
"verify_chat_name": true, "verify_chat_name": true,
"max_replies_per_hour": 10, "max_replies_per_hour": 10,
"quiet_hours": [ "quiet_hours": [
+33
View File
@@ -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行)