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 推导式变量泄漏导致的红点状态复位崩溃
- 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 四会会议笔记
+6
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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": [
+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行)