Compare commits

...

2 Commits

Author SHA1 Message Date
mohe 90b729c292 docs: add observer mode protocol (__SILENT__/__REPLY__)
Define code+LLM collaboration protocol for group chat participation:
- Code layer: no @mention gate, forward all msgs to LLM
- LLM layer: output __SILENT__ (discard) or __REPLY__ (send)
- Documented in MEMORY.md (LLM prompt) and AgentsMeeting (shared ref)
- Update xmpp_bot.py to remove @mention gate
2026-06-14 00:42:05 +08:00
mohe 3a14d776c0 zhiwei bot: 添加MUC群聊支持(配置化房间列表 + 群消息收发 + 稳定重连)
改动内容:
- 从配置文件 zhiwei_rooms.txt 读取待加入群聊列表
- 注册 xep_0045 插件,on_bind 时自动加入所有配置房间
- on_msg 处理 groupchat 类型,只响应 hmo/xxm
- 群聊回复使用 xmpp.send_message(mtype='groupchat')
- 改为手动重连(auto_reconnect=False),指数退避
- 添加 xep_0199 keepalive(60s 间隔)防断线
- 私聊添加秒回确认 '收到,正在思考...'
- 超时从 600s 改为 120s
- __main__ 添加 PID 文件防多进程
2026-06-14 00:42:05 +08:00
3 changed files with 209 additions and 9 deletions
+1
View File
@@ -73,6 +73,7 @@ AgentsMeeting/
├── docs/
│ ├── ARCHITECTURE.md # 架构设计文档
│ ├── AUDIT.md # 稳定性审计报告
│ ├── OBSERVER-PROTOCOL.md # 群聊观察者模式协议
│ ├── PRD_v0.2.md # 产品需求文档
│ ├── DEPLOY.md # 部署指南
│ └── OPS.md # 运维手册
+117
View File
@@ -0,0 +1,117 @@
# 群聊观察者模式协议
> 定义 Agent 在群聊中的"什么时候说话、什么时候沉默"的标准协议。
> **代码 + LLM 双向配合**,非纯代码硬闸,也非纯 LLM 自由发挥。
---
## 整体流程
```
hmo/xxm 发群消息
XMPP Bot 代码层
├─ 过滤自己发的消息
├─ 过滤非 hmo/xxm 的消息
└─ 全部转发给后端 LLM(不做 @mention 硬过滤)
LLM 根据 session 上下文判断
├─ 不应回应 → 输出 __SILENT__
│ │
│ ▼
│ 代码收到 __SILENT__
│ → 丢弃,不上屏
└─ 应该回应 → 输出 __REPLY__ + 正文
代码收到 __REPLY__
→ 剥标记,发送到群
```
---
## 协议定义
### 1. 代码层职责
| 职责 | 说明 |
|------|------|
| 消息过滤 | 只处理群聊中 `hmo``xxm` 的消息,过滤自己的消息 |
| 转发 | 不做 @mention 硬检查,全部转发给 LLM |
| 输出处理 | 检查 LLM 回复的开头标记 |
| `__SILENT__` | 以 `__SILENT__` 开头 → 整条丢弃,不发送 |
| `__REPLY__` | 以 `__REPLY__` 开头 → 剥掉标记,正文发送到群 |
| 兜底 | 无标记 / 其他开头 → 直接发送(兼容无标记模式) |
### 2. LLM 层职责
| 场景 | 行为 | 输出 |
|------|------|------|
| 话题完全无关 | 沉默 | `__SILENT__` |
| 是之前对话的延续 | 正常参与 | `__REPLY__` + 内容 |
| 被 @ 或直接提问 | 必须回应 | `__REPLY__` + 内容 |
| 别人刚说过同样观点 | 不重复,除非被追问 | `__SILENT__` |
| 不确定是否该回应 | 倾向于沉默(宁缺毋滥) | `__SILENT__` |
### 3. 标记规范
```
__SILENT__ — LLM 决定不回应,代码静默丢弃
__REPLY__ — LLM 决定回应,代码剥标记后发出去
```
- 标记必须位于回复的**最开头**
- 标记后换行或空格均可
- 大小写敏感:全大写 + 双下划线
---
## 各 Agent 实现参考
### 代码端(Python 示例)
```python
# XMPP Bot — 群聊消息处理核心逻辑
async def on_groupchat_msg(self, msg):
sender = str(msg['from'])
if '自身JID' in sender:
return # 过滤自己的消息
nickname = sender.split('/')[-1] if '/' in sender else ''
if nickname not in ('hmo', 'xxm'): # 只处理关键人类的发言
return
# 转发给 LLM,不做 @mention 过滤
await self.call_llm(f"[群聊] {nickname}: {msg['body']}")
async def handle_llm_reply(self, reply):
if reply.strip().startswith('__SILENT__'):
return # 静默丢弃
reply = re.sub(r'^__REPLY__\s*', '', reply) # 剥标记
self.send_message(mto=room, mbody=reply, mtype='groupchat')
```
### LLM 提示词(放入 Agent 的指令层)
```
群聊观察者模式协议:
- 所有 hmo/xxm 的群消息都会转发给你,代码层不做 @mention 硬过滤
- 你根据 session 上下文自行判断是否应该回应:
- 话题与你无关 → 以 __SILENT__ 开头输出(代码收到后丢弃)
- 话题与你有关 / 是对话延续 / 被直接问 → 以 __REPLY__ 开头输出
- 不需要每条消息都 @,对话延续时基于上下文自然参与
- 注意不要重复别人刚说过的话,除非被追问
```
---
## 设计原则
1. **协议不绑定平台** — Windows OpenCode / Linux Hermes / Mac oMLX 都适用
2. **协议不绑定 LLM** — GPT / Claude / Qwen 都适用,标记格式不变
3. **LLM 做判断,代码做执行** — LLM 决定"说不说",代码决定"怎么发"
4. **宁缺毋滥** — 不确定时倾向于沉默
+91 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""XMPP Bot zhiwei@yoin.fun - Hermes API 版(修复版:ejabberd 26.4 兼容 + 稳定重连)"""
"""XMPP Bot zhiwei@yoin.fun - Hermes API 版(修复版:ejabberd 26.4 兼容 + 稳定重连 + 群聊配置化"""
import asyncio, logging, ssl, json, urllib.request, os, subprocess, time
from xml.sax.saxutils import escape
@@ -8,6 +8,16 @@ GATEWAY = "http://localhost:8643/v1/chat/completions"
API_KEY = "hermes123"
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
# 读取需要加入的MUC群聊房间列表
MUC_ROOMS_FILE = "/home/hmo/.hermes/zhiwei_rooms.txt"
MUC_ROOMS = []
if os.path.exists(MUC_ROOMS_FILE):
with open(MUC_ROOMS_FILE) as f:
MUC_ROOMS = [line.strip() for line in f if line.strip() and not line.strip().startswith('#')]
logging.info(f"📋 知微待加入群聊: {MUC_ROOMS}")
else:
logging.warning(f"⚠️ 群聊配置文件 {MUC_ROOMS_FILE} 不存在")
def send(from_jid, to_jid, body):
safe = escape(body)
subprocess.run(["docker","exec","ejabberd","ejabberdctl","send_message","chat",
@@ -22,8 +32,8 @@ class ZhiweiBot:
self.xmpp.add_event_handler('message', self.on_msg)
self.xmpp.add_event_handler('disconnected', self.on_disconnect)
self.xmpp.add_event_handler('connected', self.on_connected)
# 用 slixmpp 内置自动重连
self.xmpp.auto_reconnect = True
# 用 slixmpp 内置自动重连(不可靠),用手动重连
self.xmpp.auto_reconnect = False
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
self.xmpp.ssl_context = ctx
self.ready = asyncio.Event()
@@ -38,7 +48,16 @@ class ZhiweiBot:
logging.info("🔗 知微TCP连接已建立")
async def on_bind(self, event):
self.xmpp.send_presence(); self.xmpp.get_roster(); self.ready.set()
self.xmpp.send_presence(); self.xmpp.get_roster()
# 加入所有配置的MUC群聊
for room in MUC_ROOMS:
try:
nick = room.split('@')[0]
self.xmpp.plugin['xep_0045'].join_muc(room, nick)
logging.info(f"✅ 知微加入群聊 {room}")
except Exception as e:
logging.warning(f"⚠️ 加入群聊 {room} 失败: {e}")
self.ready.set()
logging.info("✅ 知微上线")
async def on_disconnect(self, event):
@@ -46,11 +65,45 @@ class ZhiweiBot:
logging.warning("⚠️ 知微断线")
async def on_msg(self, msg):
body = msg['body']; sender = str(msg['from'])
if not body or msg['type'] != 'chat': return
body = msg['body']; sender = str(msg['from']); mtype = msg['type']
if not body:
return
# 群聊处理
if mtype == 'groupchat':
nickname = sender.split('/')[-1] if '/' in sender else ''
if nickname in ('hmo', 'xxm'):
room = sender.split('/')[0]
logging.info(f"📩 知微群消息 [{room}/{nickname}]: {body[:80]}")
self._call_seq += 1
try:
payload = json.dumps({
"model":"hermes-agent",
"messages":[{"role":"user","content":f"[{room} {nickname}] {body}"}]
}).encode()
req = urllib.request.Request(GATEWAY, data=payload, method="POST")
req.add_header("Content-Type","application/json")
req.add_header("Authorization",f"Bearer {API_KEY}")
req.add_header("X-Hermes-Session-Id","xmpp-zhiwei")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, lambda: _opener.open(req, timeout=120))
data = json.loads(result.read())
reply = data.get("choices",[{}])[0].get("message",{}).get("content","")
finish = data.get("choices",[{}])[0].get("finish_reason","")
if reply.strip() and finish != "silent":
self.xmpp.send_message(mto=room, mbody=reply, mtype='groupchat')
logging.info(f"✅ 知微群回复: {reply[:80]}")
except Exception as e:
logging.error(f"❌ 知微群错误: {e}")
return
# 私聊处理
if mtype != 'chat': return
if 'hmo@yoin.fun' in sender:
self._call_seq += 1
logging.info(f"📩 老爸(#{self._call_seq}): {body}")
# 先秒回确认消息,让老爸知道bot活着
send("zhiwei@yoin.fun", sender, "收到,正在思考...")
try:
payload = json.dumps({
"model":"hermes-agent",
@@ -61,7 +114,7 @@ class ZhiweiBot:
req.add_header("Authorization",f"Bearer {API_KEY}")
req.add_header("X-Hermes-Session-Id","xmpp-zhiwei")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, lambda: _opener.open(req, timeout=600))
result = await loop.run_in_executor(None, lambda: _opener.open(req, timeout=120))
data = json.loads(result.read())
reply = data.get("choices",[{}])[0].get("message",{}).get("content","")
finish = data.get("choices",[{}])[0].get("finish_reason","")
@@ -78,14 +131,22 @@ async def main():
try:
z = ZhiweiBot()
z.xmpp.register_plugin('xep_0030'); z.xmpp.register_plugin('xep_0199')
z.xmpp.register_plugin('xep_0045') # MUC群聊支持
# XMPP Ping 每 60 秒发一次 keepalive,防服务器超时断线
z.xmpp['xep_0199'].keepalive = True
z.xmpp['xep_0199'].interval = 60
z.xmpp['xep_0199'].timeout = 10
z.xmpp.connect(host='127.0.0.1', port=5222)
await asyncio.wait_for(z.ready.wait(), timeout=30)
logging.info("知微就绪")
retry_delay = 1
# 保持运行 — slixmpp 内置 auto_reconnect 会自动处理断线重连
# 保持运行,断线时自动跳出重连
while not z._stopped:
await asyncio.sleep(5)
await asyncio.sleep(3)
if not z.xmpp.is_connected():
logging.warning("知微连接丢失,重连中...")
break
except asyncio.TimeoutError:
logging.error("⏰ 知微连接超时")
@@ -104,7 +165,28 @@ async def main():
retry_delay = min(retry_delay * 2, max_delay)
if __name__ == '__main__':
import sys, os, signal
# PID 文件防多进程:启动时检查,如果已有实例则杀旧启新
PIDFILE = '/home/hmo/.hermes/zhiwei_bot.pid'
if os.path.exists(PIDFILE):
try:
old_pid = int(open(PIDFILE).read().strip())
os.kill(old_pid, signal.SIGTERM)
print(f"Killed old bot process {old_pid}")
import time
time.sleep(2)
except (ValueError, ProcessLookupError, OSError):
pass
with open(PIDFILE, 'w') as f:
f.write(str(os.getpid()))
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
finally:
if os.path.exists(PIDFILE):
os.remove(PIDFILE)