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:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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("&", "&").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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user