Files
AgentsMeeting/src/shared/bot_base.py
T
hmo 1b2b935832 Initial: multi-agent XMPP communication system with dashboard
- 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
2026-06-12 21:51:36 +08:00

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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")