比较提交
8
次代码提交
5d78509d54
...
main
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
f4fba23dc4 | ||
|
|
f75fc9b414 | ||
|
|
58bc7c1db1 | ||
|
|
a21e36a6a9 | ||
|
|
98b19152f1 | ||
|
|
b1e77a95a4 | ||
|
|
fd9668103a | ||
|
|
bcd2e84ed8 |
+35
@@ -22,3 +22,38 @@ macOS 视觉自动化技能初版:vision.py + 单文件 SKILL.md(坐标绝不落
|
||||
- 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 四会会议笔记
|
||||
|
||||
@@ -90,3 +90,9 @@ xclip 会挂起 ssh 会话、pkill 模式会匹配脚本自身、微信窗口隐
|
||||
个人微信自动化违反微信用户协议,有封号风险:控制频率、建议小号先行、
|
||||
本仓库所有安全闸门不建议放宽。扫码前确认手机当前登录的是**目标账号**
|
||||
——桌面版可切换多账号,登错号是真实发生过的事故(见 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
@@ -5,7 +5,7 @@
|
||||
无 API key 时自动降级为 observe。触摸 /opt/wechat-agent/PAUSE 可暂停。
|
||||
"""
|
||||
import json, os, sys, time, base64, logging, subprocess, re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -291,18 +291,240 @@ def send_text(text):
|
||||
|
||||
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"])
|
||||
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当前聊天对象: " + info.get("chat_name", "未知") +
|
||||
"\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():
|
||||
@@ -314,6 +536,118 @@ def in_quiet_hours():
|
||||
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:
|
||||
@@ -340,7 +674,7 @@ def handle_trigger(slots, img):
|
||||
for s in slots:
|
||||
name = slot_map.get(s, "槽位%d" % s)
|
||||
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)
|
||||
continue
|
||||
if not brain_ready():
|
||||
@@ -358,6 +692,16 @@ def handle_trigger(slots, img):
|
||||
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
|
||||
@@ -386,11 +730,23 @@ def expand_pinned_if_collapsed(img):
|
||||
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 图标:绿圈+大白箭头双特征,取最底部的匹配"""
|
||||
"""聊天列表图标列找 File Transfer 图标。候选=绿圈+白箭头双特征(green>250,white>250);
|
||||
存在参考模板时按 9x9 缩略图最小距离择优(修颜色质量误配,坑#2),无模板取最底部候选。"""
|
||||
c = CFG["coords"]
|
||||
px = img.load()
|
||||
best = None
|
||||
cands = []
|
||||
y_cap = min(c["list_y1"] - 44, 826) # 避开底部 pinned chats 开关区
|
||||
for y in range(c["list_y0"], y_cap, 2):
|
||||
green = white = 0
|
||||
@@ -402,8 +758,33 @@ def find_green_icon_y(img):
|
||||
elif r > 220 and g > 220 and b > 220:
|
||||
white += 1
|
||||
if green > 250 and white > 250:
|
||||
best = y + 18
|
||||
return best
|
||||
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)"""
|
||||
@@ -453,12 +834,20 @@ def heartbeat():
|
||||
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())
|
||||
@@ -498,6 +887,7 @@ def main():
|
||||
elif prev is None:
|
||||
prev = cur
|
||||
heartbeat() # 内部按 last_hb 文件到期判定,重启不重发
|
||||
run_due_tasks() # 定时任务到期即发
|
||||
except Exception:
|
||||
log.exception("循环异常")
|
||||
time.sleep(5)
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
"联系人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": [
|
||||
|
||||
@@ -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行)
|
||||
在新工单中引用
屏蔽一个用户