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
This commit is contained in:
hmo
2026-06-12 21:49:05 +08:00
commit 1b2b935832
76 changed files with 15943 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
"""
QQ 通道桥接 — NapCat <> AgentsMeeting 集成
状态: 骨架实现(通道就绪,需真实 QQ 账号对接)
"""
import json, logging, os
from typing import Optional
# NapCat HTTP API 地址
QQ_API_BASE = os.environ.get("QQ_API_BASE", "http://localhost:3000")
QQ_BOT_ID = os.environ.get("QQ_BOT_ID", "")
_log = logging.getLogger("qq_channel")
class QQChannel:
"""QQ 消息通道桥接。通过 NapCat HTTP API 实现消息收发。"""
def __init__(self):
self.enabled = bool(QQ_BOT_ID)
self.bot_id = QQ_BOT_ID
def send_message(self, group_id: str, text: str) -> bool:
"""发送消息到 QQ 群"""
if not self.enabled: return False
import requests
try:
resp = requests.post(
f"{QQ_API_BASE}/send_group_msg",
json={"group_id": group_id, "message": text},
timeout=10
)
return resp.status_code == 200
except Exception as e:
_log.error(f"QQ send failed: {e}")
return False
def send_private(self, user_id: str, text: str) -> bool:
"""发送私聊消息"""
if not self.enabled: return False
import requests
try:
resp = requests.post(
f"{QQ_API_BASE}/send_private_msg",
json={"user_id": user_id, "message": text},
timeout=10
)
return resp.status_code == 200
except Exception as e:
_log.error(f"QQ private failed: {e}")
return False
+36
View File
@@ -0,0 +1,36 @@
"""
Monitor XMPP group messages from the HTTP bridge.
Prints new messages as they arrive. Press Ctrl+C to stop.
Usage: python watch_group.py [--from mohe]
"""
import sys, time, json, urllib.request
URL = "http://127.0.0.1:5802/messages"
SENDER = None
args = sys.argv[1:]
for i, a in enumerate(args):
if a == "--from" and i + 1 < len(args):
SENDER = args[i + 1]
last_ts = ""
while True:
try:
url = URL
if SENDER:
url += f"?from={SENDER}"
resp = urllib.request.urlopen(url, timeout=5)
data = json.loads(resp.read())
msgs = data.get("messages", [])
new_msgs = [m for m in msgs if m["ts"] > last_ts]
for m in new_msgs:
last_ts = m["ts"]
sender_tag = f"[{m['from']}]"
print(f"\n{sender_tag} {m['body']}")
print("---")
if new_msgs:
print(f"\n({len(new_msgs)} new, waiting...)", flush=True)
except Exception as e:
print(f"(poll error: {e})", flush=True)
time.sleep(3)
+263
View File
@@ -0,0 +1,263 @@
"""
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")
+95
View File
@@ -0,0 +1,95 @@
"""
Shared configuration for AgentsMeeting bots.
All secrets via environment variables. No hardcoded keys.
Usage:
from src.shared.config import get_bot_config
cfg = get_bot_config("xxm")
"""
import os, json, yaml
from typing import Optional
# Paths
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CONFIG_DIR = os.path.join(PROJECT_ROOT, "config", "profiles")
def required_env(name: str) -> str:
"""Get required env var, fail fast if missing."""
v = os.environ.get(name, "")
if not v:
raise RuntimeError(f"Missing required env var: {name}")
return v
def optional_env(name: str, default: str = "") -> str:
"""Get optional env var with fallback."""
return os.environ.get(name, default)
class BotConfig:
"""Single bot's configuration."""
def __init__(self, profile: str):
self.profile = profile
# Load from config.yaml if exists
yaml_path = os.path.join(CONFIG_DIR, profile, "config.yaml")
yaml_cfg = {}
if os.path.exists(yaml_path):
try:
with open(yaml_path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
if raw is not None:
yaml_cfg = raw
except Exception:
pass
# Provider configs (env var overrides file config)
self.providers = {
"volcengine": {
"api_key": os.environ.get("VOLCENGINE_KEY") or _nested_get(yaml_cfg, "providers.volcengine.api_key", ""),
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
},
"ocg_new": {
"api_key": os.environ.get("OCG_NEW_KEY") or _nested_get(yaml_cfg, "providers.ocg-new.api_key", ""),
"base_url": "https://opencode.ai/zen/go/v1",
},
"ocg_old": {
"api_key": os.environ.get("OCG_OLD_KEY") or _nested_get(yaml_cfg, "providers.ocg-old.api_key", ""),
"base_url": "https://opencode.ai/zen/go/v1",
},
}
# XMPP config
self.jid = os.environ.get(f"{profile.upper()}_JID") or _nested_get(yaml_cfg, "xmpp.jid", f"{profile}@yoin.fun")
self.password = os.environ.get(f"{profile.upper()}_PASS") or _nested_get(yaml_cfg, "xmpp.password", "")
self.xmpp_host = os.environ.get("XMPP_HOST", "xmpp.yoin.fun")
self.xmpp_port = int(os.environ.get("XMPP_PORT", "3021"))
self.muc_rooms = (os.environ.get("MUC_ROOMS", "coregroup@conference.yoin.fun")).split(",")
# Session config
self.session_id = os.environ.get(f"{profile.upper()}_SESSION") or _nested_get(yaml_cfg, "session.id", f"ses_{profile}")
# Model config
self.model = os.environ.get("DEFAULT_MODEL", "deepseek-v4-flash")
self.provider = os.environ.get("DEFAULT_PROVIDER", "volcengine")
# API config
self.api_timeout = int(os.environ.get("API_TIMEOUT", "60"))
self.max_tool_loops = int(os.environ.get("MAX_TOOL_LOOPS", "30"))
def _nested_get(d: dict, path: str, default=""):
"""Get nested dict value by dot-separated path."""
parts = path.split(".")
for p in parts:
if isinstance(d, dict) and p in d:
d = d[p]
else:
return default
return d
def get_bot_config(profile: str) -> BotConfig:
"""Factory: load config for a bot profile."""
return BotConfig(profile)