Compare commits
8
Commits
5d78509d54
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fba23dc4 | ||
|
|
f75fc9b414 | ||
|
|
58bc7c1db1 | ||
|
|
a21e36a6a9 | ||
|
|
98b19152f1 | ||
|
|
b1e77a95a4 | ||
|
|
fd9668103a | ||
|
|
bcd2e84ed8 |
@@ -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 四会会议笔记
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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