1b2b935832
- Platform-based architecture (Windows/Linux/Mac) - Agent instance registry (agents.yaml) - Management dashboard with cross-platform monitoring - xmpp_bot with HTTP bridge + health endpoints - wechat_agent with WeChat-Hermes bridging - Platform services: ProcessGuardian, HealthProbe, APIRouter, ChannelBridge - Deployment: systemd (Linux) + PowerShell (Windows) - Monitoring: SSH+ejabberdctl for cross-platform presence
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""
|
|
Base class for all AgentsMeeting XMPP bots.
|
|
Provides: PID lock, connection lifecycle, event routing, dedup, batching, silence detection.
|
|
|
|
Usage:
|
|
from src.shared.bot_base import BaseBot, BotConfig
|
|
cfg = BotConfig(jid="xxm@yoin.fun", password="hermes123", ...)
|
|
bot = BaseBot(cfg)
|
|
bot.start()
|
|
"""
|
|
import os, sys, time, threading, asyncio, logging, re
|
|
from typing import Optional, Callable
|
|
|
|
# ── Ensure Windows selector loop policy ──
|
|
if sys.platform == "win32":
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
|
|
import slixmpp
|
|
|
|
|
|
class BotConfig:
|
|
"""Per-bot configuration."""
|
|
def __init__(self, *, jid: str, password: str, host: str = "xmpp.yoin.fun",
|
|
port: int = 3021, muc_rooms: list[str] = None, nick: str = "",
|
|
session_id: str = "", python_path: str = "", log_dir: str = ""):
|
|
self.jid = jid
|
|
self.password = password
|
|
self.host = host
|
|
self.port = port
|
|
self.muc_rooms = muc_rooms or []
|
|
self.nick = nick or jid.split("@")[0]
|
|
self.session_id = session_id or f"ses_{self.nick}"
|
|
self.python_path = python_path or sys.executable
|
|
self.log_dir = log_dir or os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "logs")
|
|
|
|
|
|
class BaseBot(slixmpp.ClientXMPP):
|
|
"""
|
|
Base XMPP bot with:
|
|
- PID lock (proc_guard)
|
|
- Auto-reconnect
|
|
- MUC room join (silent observer)
|
|
- Message dedup
|
|
- Silence/shutup protocol
|
|
- Message batching (3s debounce)
|
|
- Response extraction (__SILENT__/__REPLY__)
|
|
"""
|
|
|
|
def __init__(self, config: BotConfig):
|
|
super().__init__(config.jid, config.password)
|
|
self.cfg = config
|
|
|
|
# XMPP settings
|
|
self.enable_direct_tls = False
|
|
self.enable_starttls = True
|
|
self.auto_reconnect = True
|
|
self.reconnect_max_delay = 10
|
|
|
|
# MUC plugin
|
|
self.register_plugin('xep_0045')
|
|
|
|
# Event handlers
|
|
self.add_event_handler("session_start", self._on_session_start)
|
|
self.add_event_handler("message", self._on_message)
|
|
self.add_event_handler("groupchat_message", self._on_groupchat_message)
|
|
self.add_event_handler("disconnected", self._on_disconnected)
|
|
|
|
# Callbacks (override in subclass)
|
|
self.on_private_message: Optional[Callable] = None
|
|
self.on_group_message: Optional[Callable] = None
|
|
|
|
# State
|
|
self._dedup: set[str] = set()
|
|
self._dedup_lock = threading.Lock()
|
|
self._silence_until: float = 0.0
|
|
self._silence_lock = threading.Lock()
|
|
|
|
# Shutup patterns
|
|
self.shutup_patterns = ["闭嘴", "住口", "shut up", "shutup"]
|
|
self.silence_seconds = 30
|
|
|
|
# Batching
|
|
self._batches: dict[str, list[str]] = {}
|
|
self._batch_timers: dict[str, threading.Timer] = {}
|
|
self._batch_processing: set[str] = set()
|
|
self._batch_pending: dict[str, list[str]] = {}
|
|
self._batch_window = 3.0
|
|
self._batch_lock = threading.Lock()
|
|
|
|
# Logging
|
|
os.makedirs(self.cfg.log_dir, exist_ok=True)
|
|
self._log_file = os.path.join(self.cfg.log_dir, f"{self.cfg.nick}_bot.log")
|
|
|
|
# ── Logging ──────────────────────────────────────
|
|
def log(self, msg: str):
|
|
ts = time.strftime("%H:%M:%S")
|
|
with open(self._log_file, "a", encoding="utf-8") as f:
|
|
f.write(f"{ts} {msg}\n")
|
|
|
|
# ── Dedup ────────────────────────────────────────
|
|
def _is_duplicate(self, msg_id: str) -> bool:
|
|
if not msg_id: return False
|
|
with self._dedup_lock:
|
|
if msg_id in self._dedup: return True
|
|
self._dedup.add(msg_id)
|
|
if len(self._dedup) > 1000: self._dedup.clear()
|
|
return False
|
|
|
|
# ── Silence ──────────────────────────────────────
|
|
def _is_silenced(self) -> bool:
|
|
with self._silence_lock:
|
|
return time.time() < self._silence_until
|
|
|
|
def _check_shutup(self, body: str) -> bool:
|
|
lower = body.lower().strip()
|
|
for pat in self.shutup_patterns:
|
|
if pat.lower() in lower:
|
|
with self._silence_lock:
|
|
self._silence_until = time.time() + self.silence_seconds
|
|
self.log(f"(shutup: '{pat}' → {self.silence_seconds}s)")
|
|
return True
|
|
return False
|
|
|
|
# ── Batching ─────────────────────────────────────
|
|
def _fire_batch(self, room: str):
|
|
with self._batch_lock:
|
|
entries = self._batches.pop(room, None)
|
|
self._batch_timers.pop(room, None)
|
|
if not entries: return
|
|
self._batch_processing.add(room)
|
|
combined = "\n".join(entries)
|
|
if self.on_group_message:
|
|
threading.Thread(target=lambda: self.on_group_message(room, combined), daemon=True).start()
|
|
|
|
def _batch_message(self, room: str, nickname: str, body: str) -> bool:
|
|
if f"@{self.cfg.nick}" in body or body.startswith(self.cfg.nick):
|
|
return False # bypass batch for @mentions
|
|
formatted = f"[{nickname}]: {body}"
|
|
with self._batch_lock:
|
|
if room in self._batch_processing:
|
|
self._batch_pending.setdefault(room, []).append(formatted)
|
|
return True
|
|
timer = self._batch_timers.pop(room, None)
|
|
if timer: timer.cancel()
|
|
self._batches.setdefault(room, []).append(formatted)
|
|
t = threading.Timer(self._batch_window, self._fire_batch, args=[room])
|
|
t.daemon = True
|
|
t.start()
|
|
self._batch_timers[room] = t
|
|
return True
|
|
|
|
def _batch_done(self, room: str):
|
|
with self._batch_lock:
|
|
self._batch_processing.discard(room)
|
|
pending = self._batch_pending.pop(room, None)
|
|
if pending:
|
|
self._batches[room] = pending
|
|
t = threading.Timer(0.1, self._fire_batch, args=[room])
|
|
t.daemon = True
|
|
t.start()
|
|
self._batch_timers[room] = t
|
|
|
|
# ── Send ─────────────────────────────────────────
|
|
def send_group(self, room: str, text: str):
|
|
safe = text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
stanza = f'<message to="{room}" type="groupchat"><body>{safe}</body></message>'
|
|
self.send_raw(stanza)
|
|
|
|
def send_private(self, to: str, text: str):
|
|
safe = text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
stanza = f'<message to="{to}" type="chat"><body>{safe}</body></message>'
|
|
self.send_raw(stanza)
|
|
|
|
# ── Response extraction ──────────────────────────
|
|
@staticmethod
|
|
def extract_response(text: str) -> Optional[str]:
|
|
if not text or not text.strip(): return None
|
|
t = text.strip()
|
|
if t.startswith("__SILENT__"):
|
|
parts = t.split("\n", 1)
|
|
return None if len(parts) < 2 else parts[1].strip() or None
|
|
# Strip tool call XML
|
|
t = re.sub(r'<tool_calls>.*?(</tool_calls>|$)', '', t, flags=re.DOTALL)
|
|
t = re.sub(r'<invoke\s+[^>]*>.*?(</invoke>|$)', '', t, flags=re.DOTALL)
|
|
t = t.strip()
|
|
return t or None
|
|
|
|
# ── Event handlers ───────────────────────────────
|
|
def _on_session_start(self, event):
|
|
self.send_presence()
|
|
self.get_roster()
|
|
self.log(f"{self.cfg.jid} online")
|
|
# Join MUC rooms
|
|
async def _join():
|
|
for room in self.cfg.muc_rooms:
|
|
for attempt in range(3):
|
|
try:
|
|
await self.plugin['xep_0045'].join_muc_wait(room, self.cfg.nick, timeout=30)
|
|
self.log(f"Joined {room}")
|
|
break
|
|
except asyncio.TimeoutError:
|
|
self.log(f"MUC join timeout ({attempt+1}/3) for {room}")
|
|
await asyncio.sleep(3)
|
|
except Exception as e:
|
|
self.log(f"MUC join failed: {room}: {e}")
|
|
break
|
|
asyncio.ensure_future(_join())
|
|
|
|
def _on_message(self, msg):
|
|
if msg["type"] == "groupchat": return
|
|
msg_id = msg.get("id", "")
|
|
if self._is_duplicate(msg_id): return
|
|
body = str(msg["body"]).strip()
|
|
sender = str(msg["from"]).split("/")[0]
|
|
if sender == self.cfg.jid:
|
|
self.log(f"(skipped self)")
|
|
return
|
|
if self._is_silenced():
|
|
self.log(f"(silenced) {sender}: {body[:60]}")
|
|
return
|
|
if self._check_shutup(body): return
|
|
self.log(f"<{sender}> {body[:80]}")
|
|
if self.on_private_message:
|
|
threading.Thread(target=lambda: self.on_private_message(sender, body), daemon=True).start()
|
|
|
|
def _on_groupchat_message(self, msg):
|
|
msg_id = msg.get("id", "")
|
|
if self._is_duplicate(msg_id): return
|
|
body = str(msg["body"]).strip()
|
|
if not body: return
|
|
full_from = str(msg["from"])
|
|
room = full_from.split("/")[0]
|
|
nickname = full_from.split("/")[1] if "/" in full_from else ""
|
|
# Self-message — record to context
|
|
if nickname == self.cfg.nick:
|
|
self.log(f"(self) {body[:80]}")
|
|
return
|
|
if self._is_silenced():
|
|
self.log(f"(group silenced) {body[:60]}")
|
|
return
|
|
if self._check_shutup(body):
|
|
self.log(f"(group shutup)")
|
|
return
|
|
# Batch or immediate
|
|
if self._batch_message(room, nickname, body):
|
|
self.log(f"[{room.split('@')[0]}] {nickname}: {body[:80]} (batched)")
|
|
return
|
|
self.log(f"[{room.split('@')[0]}] {nickname}: {body[:80]}")
|
|
if self.on_group_message:
|
|
threading.Thread(target=lambda: self.on_group_message(room, f"[{nickname}]: {body}"), daemon=True).start()
|
|
|
|
def _on_disconnected(self, event):
|
|
self.log(f"disconnected, reconnecting...")
|
|
|
|
# ── Startup ──────────────────────────────────────
|
|
def start(self):
|
|
self.connect((self.cfg.host, self.cfg.port))
|
|
self.log(f"Connecting {self.cfg.jid}@{self.cfg.host}:{self.cfg.port}")
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
loop.run_forever()
|
|
except KeyboardInterrupt:
|
|
self.log("Shutdown")
|