""" 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'{safe}' self.send_raw(stanza) def send_private(self, to: str, text: str): safe = text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) stanza = f'{safe}' 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'.*?(|$)', '', t, flags=re.DOTALL) t = re.sub(r']*>.*?(|$)', '', 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")