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
+285
View File
@@ -0,0 +1,285 @@
"""
api_proxy.py — 多 upstream API 错误码吞掉代理
监听本地端口,转发请求到 upstream API。
- 2xx: 透传,完全透明
- 429/5xx: 自动重试最多 3 次(指数退避 1s/2s/4s)
- 重试耗尽: 返回假 200,错误信息嵌入响应文本
→ opencode 永远看不到 HTTP 错误码,retry-cache 永不触发
支持多 upstream 路由:
volcengine / opencode-go-new — 按模型名自动选择
"""
import os, sys, json, time, logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import Request, urlopen, HTTPError
from urllib.error import URLError
# ── 项目根目录 ──────────────────────────────────────────────
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(PROJECT_ROOT, "scripts"))
from proc_guard import guard # PID 锁
# ── 配置 ────────────────────────────────────────────────────
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 8787
MAX_RETRIES = 3
RETRY_DELAYS = [1, 2, 4] # seconds
# upstream 路由表: 名称 → base URL + API key
UPSTREAMS = {
"volcengine": {
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
"api_key": "b0359bed-09f2-49e2-a53c-32ba057412e3",
},
"opencode-go-new": {
"base_url": "https://opencode.ai/zen/go/v1",
"api_key": "sk-5miR8xAMhlaXWJz3kXoYPub4ZSUISr8Fy3BXN7teThGkWonQAjZmeJdMu17htGTB",
},
"opencode-go-old": {
"base_url": "https://opencode.ai/zen/go/v1",
"api_key": "sk-MBLGxsGQU1Ngr1M7DKMt1TiCKvOEdKiwClwiUTcOPJKRZ4wbrgKZ25l3dHmvozhj",
},
}
# 模型路由: 模型名 → 走哪个 upstream
# 支持 volcengine 原生模型名(如 deepseek-v4-flash)也支持 proxy 安全名(如 deepseek-v4-flash-go-safe
MODEL_ROUTES = {
"deepseek-v4-flash-safe": "volcengine",
"deepseek-v4-pro-safe": "volcengine",
"deepseek-v4-flash-go-safe": "opencode-go-new",
"deepseek-v4-pro-go-safe": "opencode-go-new",
# volcengine 原生模型名 → 走 opencode-go(劫持 volcengine baseURL 后使用)
"deepseek-v4-flash": "opencode-go-new",
"deepseek-v4-pro": "opencode-go-new",
}
# 模型名映射: 安全名 → upstream 真正用的名称
MODEL_MAP = {
"deepseek-v4-flash-safe": "deepseek-v4-flash",
"deepseek-v4-pro-safe": "deepseek-v4-pro",
"deepseek-v4-flash-go-safe": "deepseek-v4-flash",
"deepseek-v4-pro-go-safe": "deepseek-v4-pro",
}
# 默认 upstream(当模型名不在路由表中时)
DEFAULT_UPSTREAM = "volcengine"
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(os.path.join(LOG_DIR, "api_proxy.log"), encoding="utf-8"),
logging.StreamHandler(),
],
)
log = logging.getLogger("api_proxy")
def make_fake_response(model: str, error_text: str, stream: bool) -> tuple:
"""构造假 200 响应,把错误信息嵌入文案。"""
content = f"[api_proxy] upstream API 请求失败({MAX_RETRIES} 次重试均未成功)。原始错误: {error_text}"
if stream:
fake_chunks = [
f'data: {{"choices":[{{"delta":{{"role":"assistant","content":""}},"index":0}}]}}\n\n',
f'data: {{"choices":[{{"delta":{{"content":{json.dumps(content)}}},"index":0}}]}}\n\n',
f"data: [DONE]\n\n",
]
body = "".join(fake_chunks).encode("utf-8")
return body, "text/event-stream"
else:
resp = {
"id": "api_proxy_error_fallback",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
return json.dumps(resp, ensure_ascii=False).encode("utf-8"), "application/json"
class ProxyHandler(BaseHTTPRequestHandler):
"""HTTP 请求代理处理器。"""
# 禁止 BaseHTTPRequestHandler 写日志到 stderr(我们自己记)
def log_message(self, format, *args):
pass
def do_GET(self):
self._handle_request("GET")
def do_POST(self):
self._handle_request("POST")
def do_DELETE(self):
self._handle_request("DELETE")
def _handle_request(self, method):
start = time.time()
req_id = f"{method}{self.path}"[:80]
log.info("%s %s", method, self.path)
# 读取请求体
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else b""
# 解析模型名、路由和流式标记
is_stream = False
model_in = ""
upstream_name = DEFAULT_UPSTREAM
if body:
try:
req_json = json.loads(body)
is_stream = req_json.get("stream", False)
model_in = req_json.get("model", "")
# 根据模型名选 upstream
upstream_name = MODEL_ROUTES.get(model_in, DEFAULT_UPSTREAM)
# 模型名映射: 安全名 → 真实 upstream 名
if model_in in MODEL_MAP:
real_model = MODEL_MAP[model_in]
req_json["model"] = real_model
body = json.dumps(req_json, ensure_ascii=False).encode("utf-8")
log.info(" model remap: %s%s (upstream: %s)", model_in, real_model, upstream_name)
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# 构建 upstream URL(从路由表选 base URL
upstream_cfg = UPSTREAMS.get(upstream_name, UPSTREAMS[DEFAULT_UPSTREAM])
upstream_url = upstream_cfg["base_url"] + self.path
# 准备转发的头部(过滤 hop-by-hop 头)
excluded_headers = {
"host", "connection", "keep-alive", "proxy-authenticate",
"proxy-authorization", "te", "trailers", "transfer-encoding",
"upgrade", "content-length", "content-encoding",
}
upstream_headers = {
k: v for k, v in self.headers.items()
if k.lower() not in excluded_headers
}
# 用 upstream 自己的 API key 覆盖客户端传过来的
if "api_key" in upstream_cfg:
upstream_headers["Authorization"] = f"Bearer {upstream_cfg['api_key']}"
last_error = ""
last_status = 0
for attempt in range(1, MAX_RETRIES + 1):
try:
upstream_headers.pop("Content-Length", None)
req = Request(upstream_url, data=body, headers=upstream_headers, method=method)
with urlopen(req, timeout=120) as resp:
# 成功 — 透传
status = resp.status
if status < 400:
resp_body = resp.read()
self._send_response(status, resp.headers, resp_body)
elapsed = time.time() - start
log.info("%s %s%d (%.2fs)", method, self.path, status, elapsed)
return
# 4xx/5xx — 记录准备重试
last_status = status
last_error = f"HTTP {status}: {resp.read().decode('utf-8', errors='replace')[:500]}"
log.warning("⚠ attempt %d/%d: %s", attempt, MAX_RETRIES, last_error)
except HTTPError as e:
last_status = e.code
last_error = f"HTTP {e.code}: {e.read().decode('utf-8', errors='replace')[:500]}"
log.warning("⚠ attempt %d/%d: %s", attempt, MAX_RETRIES, last_error)
except URLError as e:
last_status = 0
last_error = f"URLError: {e.reason}"
log.warning("⚠ attempt %d/%d: %s", attempt, MAX_RETRIES, last_error)
except Exception as e:
last_status = 0
last_error = f"Exception: {e}"
log.warning("⚠ attempt %d/%d: %s", attempt, MAX_RETRIES, last_error)
# 最后一次尝试失败了,不 sleep
if attempt < MAX_RETRIES:
delay = RETRY_DELAYS[min(attempt - 1, len(RETRY_DELAYS) - 1)]
log.info(" sleep %ds before retry %d/%d", delay, attempt + 1, MAX_RETRIES)
time.sleep(delay)
# ── 所有重试耗尽,返回假 200 ──
model_name = "unknown"
if body:
try:
req_json = json.loads(body)
model_name = req_json.get("model", "unknown")
except Exception:
pass
log.warning("%s %s — 重试耗尽, model=%s, error=%s", method, self.path, model_name, last_error)
fake_body, content_type = make_fake_response(model_name, last_error, is_stream)
fake_headers = {
"Content-Type": content_type,
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
}
self._send_response(200, fake_headers, fake_body)
elapsed = time.time() - start
log.info("%s %s → fake 200 (%.2fs)", method, self.path, elapsed)
def _send_response(self, status: int, headers, body: bytes):
"""发送响应给客户端。"""
self.send_response(status)
# 透传安全/有用的响应头
allowed = {"content-type", "content-encoding", "cache-control",
"x-request-id", "x-ratelimit-remaining", "x-ratelimit-reset",
"access-control-allow-origin"}
if isinstance(headers, dict):
for k, v in headers.items():
if k.lower() in allowed:
self.send_header(k, v)
else:
# http.client.HTTPMessage 对象
for k, v in headers.items():
if k.lower() in allowed:
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
self.wfile.flush()
def main():
# PID 锁
g = guard("api_proxy")
if not g.ok:
log.error("api_proxy 已有实例在运行 (PID %s),退出", g.pid)
sys.exit(1)
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), ProxyHandler)
log.info("api_proxy 启动 → http://%s:%d", LISTEN_HOST, LISTEN_PORT)
for name, cfg in UPSTREAMS.items():
log.info(" upstream [%s]: %s", name, cfg["base_url"])
log.info("retry: %d 次, 退避 %s", MAX_RETRIES, RETRY_DELAYS)
log.info("重试耗尽后返回 fake 200opencode retry-cache 永不触发)")
try:
server.serve_forever()
except KeyboardInterrupt:
log.info("收到中断信号,关闭服务器...")
server.shutdown()
if __name__ == "__main__":
main()
+619
View File
@@ -0,0 +1,619 @@
"""
Chat Bridge — direct HTTP API calls with model fallback + session persistence.
Messages are dual-written:
- `.bridge_context.jsonl` for immediate context injection
- `opencode.db` (serve session) so session_search works for old messages
Context window: last 200 messages from session (hard limit, no compression).
Beyond 200: use session_search (##list_sessions## / ##switch_session##).
"""
import os, json, time, logging, sqlite3
from datetime import datetime, timezone, timedelta
from session_router import extract_session_context
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
import requests
_TZ = timezone(timedelta(hours=8))
# ── Logging ──
_LOG_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"logs", "bridge.log")
os.makedirs(os.path.dirname(_LOG_FILE), exist_ok=True)
_logger = logging.getLogger("chat_bridge")
_handler = logging.FileHandler(_LOG_FILE, encoding="utf-8")
_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
_logger.addHandler(_handler)
_logger.setLevel(logging.INFO)
# ── Provider configs from opencode config.json ──
_CONFIG_PATH = os.path.join(os.environ.get("USERPROFILE", "C:\\Users\\hmo"),
".config", "opencode", "config.json")
def _load_providers() -> dict:
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f).get("provider", {})
except Exception as e:
_logger.error("Failed to load provider config: %s", e)
return {}
_PROVIDERS = _load_providers()
# ── Build provider chain dynamically from config ──
# Don't hardcode which providers have quota — try everything configured.
# Each provider's model name comes from its config (options.model),
# falling back to a sensible default based on provider key.
_DEFAULT_MODELS = {
"volcengine": "deepseek-v4-flash",
"opencode-go": "deepseek-v4-flash",
"opencode-go-new": "deepseek-v4-flash",
"deepseek": "deepseek-v4-flash",
"sense-nova": "nova-4",
}
def _build_chain() -> list[tuple[str, str, str]]:
"""Build (provider_key, base_url, model_name) in priority order.
优先用 volcengine(额度/免费)→ opencode-go-new(订阅)→ opencode-go(备用)。
deepseek(直连)作为最后兜底,额度不够时启用。
"""
allowed = ["volcengine", "opencode-go", "opencode-go-new"]
chain = []
for key in allowed:
prov = _PROVIDERS.get(key)
if not prov:
continue
opts = prov.get("options", {})
base = opts.get("baseURL", "")
api_key = opts.get("apiKey", "")
if not base or not api_key:
continue
model = opts.get("model") or _DEFAULT_MODELS.get(key, "deepseek-v4-flash")
chain.append((key, base, model))
return chain
DEFAULT_TIMEOUT = 60 # per model, in seconds
LOCK_DURATION = 300 # reuse good provider for 5 min
FAILED_BACKOFF = 1800 # skip failed provider for 30 min
_last_good_provider: str | None = None
_last_good_time: float = 0.0
_failed_providers: dict[str, float] = {}
_CACHE_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"temp", ".model_cache.json")
def _load_cache():
global _last_good_provider, _last_good_time
try:
with open(_CACHE_FILE, "r") as f:
d = json.load(f)
_last_good_provider = d.get("provider")
_last_good_time = d.get("time", 0.0)
except (FileNotFoundError, json.JSONDecodeError, ValueError):
pass
def _save_cache():
d = {"provider": _last_good_provider, "time": _last_good_time}
try:
os.makedirs(os.path.dirname(_CACHE_FILE), exist_ok=True)
with open(_CACHE_FILE, "w") as f:
json.dump(d, f)
except Exception:
pass
def _cache_model(provider_key: str):
global _last_good_provider, _last_good_time
_last_good_provider = provider_key
_last_good_time = time.time()
_save_cache()
def _get_providers_to_try() -> list[tuple[str, str, str]]:
"""
Returns [(provider_key, base_url, model_name), ...] to try.
"""
global _last_good_provider, _last_good_time, _failed_providers
now = time.time()
_failed_providers = {p: t for p, t in _failed_providers.items() if now < t}
chain = _build_chain()
# Lock active — reuse last good provider
if _last_good_provider and (now - _last_good_time) < LOCK_DURATION:
for key, base, model in chain:
if key == _last_good_provider:
return [(key, base, model)]
# Build available list
available = []
for key, base, model in chain:
if key in _failed_providers:
continue
available.append((key, base, model))
if not available and _last_good_provider:
for key, base, model in chain:
if key == _last_good_provider:
available.append((key, base, model))
break
return available
_load_cache()
# ── Tool definitions (function calling) ──
_TOOLS = [
{
"type": "function",
"function": {
"name": "run_command",
"description": "执行一条 shell 命令。可以 SSH 到远程服务器(如 root@47.115.32.206)。",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "要执行的 shell 命令"
}
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "session_search",
"description": "搜索其他 session 的历史对话内容。默认查当前 session(xxm 自己的 session),也可查指定 session(如 TUI 工作台 ses_1d95d15c4ffehQaZ6hrbIbak5k)。返回最近 N 条消息(带时间戳和来源标记),按时间正序排列。",
"parameters": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "要查询的 session ID。不传或传空字符串则查 TUI session"
},
"limit": {
"type": "integer",
"description": "返回最近多少条消息,默认 20,最大 100"
}
}
}
}
}
]
_MAX_TOOL_LOOPS = 30 # 超限后走 clean final force,不再泄漏 XML
def _run_tool_command(cmd: str) -> str:
"""Execute a shell command and return output."""
import subprocess as _sp
try:
r = _sp.run(cmd, shell=True, capture_output=True, timeout=60,
text=True, encoding='utf-8', errors='replace')
out = (r.stdout or "") + (r.stderr or "")
return out.strip() or f"(exit code {r.returncode}, no output)"
except _sp.TimeoutExpired:
return "(命令超时)"
except Exception as e:
return f"(执行失败: {e})"
# ── Serve session DB path ──
_SERVE_DB = os.path.join(
os.environ.get("USERPROFILE", "C:\\Users\\hmo"),
".local", "share", "opencode", "opencode.db")
class SessionBridge:
"""
Send message to LLM via direct HTTP API call.
Injects recent conversation context for continuity.
Context comes from the serve session (opencode.db, last 200 msgs),
with .bridge_context.jsonl as fallback.
Messages are written back to the session so session_search works.
"""
def __init__(self, session_id: str = "", serve_url: str = "",
temp_dir: str = "", timeout: int = DEFAULT_TIMEOUT):
self.session_id = session_id
self.timeout = timeout
self.temp_dir = temp_dir or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp")
os.makedirs(self.temp_dir, exist_ok=True)
# ── Conversation log (fallback / debug) ──
self._ctx_log = os.path.join(self.temp_dir, ".bridge_context.jsonl")
self._ctx_max = 200
# ── Context management ─────────────────────────────────
def _read_recent_context(self) -> str:
"""Read last N exchanges, newest → oldest (top = most recent).
Priority:
1. Session (opencode.db) via extract_session_context() — has timestamps
2. Fallback: .bridge_context.jsonl — also with timestamps
Each line is prefixed with [MM-DD HH:MM] so LLM can judge recency.
"""
# Priority 1: session
if self.session_id:
try:
from session_router import extract_session_context
ctx = extract_session_context(self.session_id, limit=self._ctx_max)
if ctx:
return ctx
except Exception:
pass
# Priority 2: .bridge_context.jsonl fallback
try:
if not os.path.exists(self._ctx_log):
return ""
with open(self._ctx_log, "r", encoding="utf-8") as f:
raw = f.readlines()
recent = raw[-self._ctx_max:]
parts = []
for line in recent:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
ts = entry.get("ts", 0)
role = entry.get("role", "?")
content = str(entry.get("content", ""))
source = entry.get("source", "bridge")
role_label = "用户" if role == "user" else "小小莫"
src_tag = {"xmpp": "[群聊] ", "vc": "[VC] ", "tui": "[TUI] ", "bridge": "[桥接] "}.get(source, f"[{source}] ")
ts_str = ""
if ts:
ts_str = datetime.fromtimestamp(ts, tz=_TZ).strftime("%m-%d %H:%M")
line_str = f"{ts_str} {src_tag}{role_label}: {content}" if ts_str else f"{src_tag}{role_label}: {content}"
parts.append(line_str)
except (json.JSONDecodeError, ValueError):
continue
return "\n".join(parts)
except Exception:
return ""
def _append_to_log(self, role: str, content: str, source: str = "bridge"):
try:
entry = json.dumps(
{"ts": int(time.time()), "role": role, "content": content, "source": source},
ensure_ascii=False)
with open(self._ctx_log, "a", encoding="utf-8") as f:
f.write(entry + "\n")
self._trim_log()
except Exception:
pass
def _trim_log(self):
try:
with open(self._ctx_log, "r", encoding="utf-8") as f:
lines = f.readlines()
if len(lines) > self._ctx_max * 2:
with open(self._ctx_log, "w", encoding="utf-8") as f:
f.writelines(lines[-self._ctx_max:])
except Exception:
pass
def _append_to_session(self, role: str, content: str, source: str = "bridge",
model_info: dict | None = None):
"""Write a message to the serve session (opencode.db).
Fields match the opencode session message schema (v1.17+):
- mode: 'user' (user) / 'Sisyphus - Ultraworker' (assistant)
- tokens / cost: always present so UI doesn't crash on null
- model: only for assistant messages
- finish: 'stop' for assistant messages
source distinguishes bridge-injected (xmpp/vc) vs native TUI messages.
"""
import uuid as _uuid
if not self.session_id:
return
try:
now_ms = int(time.time() * 1000)
msg_id = "msg_" + _uuid.uuid4().hex[:24]
part_id = "prt_" + _uuid.uuid4().hex[:24]
default_tokens = {"input": 0, "output": 0}
default_cost = {"input": 0, "output": 0}
if role == "user":
data = {
"role": "user", "source": source, "mode": "user",
"tokens": default_tokens, "cost": default_cost,
}
else:
data = {
"role": "assistant", "source": source, "mode": "Sisyphus - Ultraworker",
"tokens": default_tokens, "cost": default_cost,
"finish": "stop",
}
if model_info:
data["model"] = model_info
msg_data = json.dumps(data, ensure_ascii=False)
part_data = json.dumps({"type": "text", "text": content}, ensure_ascii=False)
conn = sqlite3.connect(_SERVE_DB)
conn.execute(
"INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
(msg_id, self.session_id, msg_data, now_ms, now_ms),
)
conn.execute(
"INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)",
(part_id, msg_id, self.session_id, part_data, now_ms, now_ms),
)
conn.commit()
conn.close()
_logger.debug("append_to_session: %s → session %s (%d chars)",
role, self.session_id[:20], len(content))
except Exception as e:
_logger.warning("append_to_session failed: %s", e)
# ── Direct API call ──────────────────────────────────
def _call_api(self, provider_key: str, base_url: str, model: str,
messages: list, timeout: int) -> str | None:
"""
Send messages to LLM via function calling API (using `requests`).
Handles tool_calls loop internally.
Returns final text response after all tool calls resolved.
Timeout: connect=10s, read=timeout (ensures no infinite hang)
"""
prov = _PROVIDERS.get(provider_key)
if not prov:
_logger.error("Provider %s not found in config", provider_key)
return None
api_key = prov.get("options", {}).get("apiKey", "")
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
})
# Bypass system proxy (v2rayN :15000) — proxy causes permanent hang with volcengine API
session.trust_env = False
session.proxies = {"http": None, "https": None}
for loop in range(_MAX_TOOL_LOOPS):
url = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": model,
"messages": messages,
"tools": _TOOLS,
}
t0 = time.time()
try:
resp = session.post(url, json=payload, timeout=(10, timeout))
resp.raise_for_status()
body = resp.json()
except requests.exceptions.Timeout:
_logger.warning("API %s/%s timeout (%ds) at loop %d",
provider_key, model, timeout, loop)
return None
except requests.exceptions.HTTPError as e:
err_body = ""
try:
err_body = e.response.text[:300]
except Exception:
pass
code = e.response.status_code if e.response is not None else 0
_logger.warning("API %s/%s HTTP %d: %s",
provider_key, model, code, err_body)
return None
except requests.exceptions.RequestException as e:
_logger.warning("API %s/%s request failed: %s",
provider_key, model, e)
return None
msg = body.get("choices", [{}])[0].get("message", {})
content = msg.get("content", "")
tool_calls = msg.get("tool_calls")
# No tool calls → final answer
if not tool_calls:
if content and content.strip():
elapsed = time.time() - t0
_logger.info("API %s/%s OK (%.1fs, loop %d)",
provider_key, model, elapsed, loop)
return content.strip()
# Empty content with no tool calls → something wrong
if loop == 0:
return None
return ""
# Has tool calls → execute them
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
for tc in tool_calls:
if tc.get("type") != "function":
continue
fn = tc.get("function", {})
fn_name = fn.get("name", "")
fn_args_str = fn.get("arguments", "{}")
tool_call_id = tc.get("id", "")
if fn_name == "run_command":
try:
fn_args = json.loads(fn_args_str)
cmd = fn_args.get("command", "")
except (json.JSONDecodeError, ValueError):
cmd = ""
output = _run_tool_command(cmd) if cmd else "(no command)"
_logger.info(" tool: run_command → %s (%d chars)", cmd[:80], len(output))
elif fn_name == "session_search":
try:
fn_args = json.loads(fn_args_str)
sid = fn_args.get("session_id", "") or self.session_id
limit = min(int(fn_args.get("limit", 20)), 100)
except (json.JSONDecodeError, ValueError, TypeError):
sid = self.session_id
limit = 20
ctx = extract_session_context(sid, limit=limit)
output = ctx if ctx else f"(session {sid}: no messages)"
_logger.info(" tool: session_search → %s (%d chars)", sid[:32], len(output))
else:
output = f"(unknown tool: {fn_name})"
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": output[:2000], # trim to avoid context overflow
})
_logger.warning("API %s/%s: max tool loops (%d) reached, forcing final answer",
provider_key, model, _MAX_TOOL_LOOPS)
# 循环上限到了,重发一次不带工具的 API。
# 关键:滤掉所有工具调用脏记录,只留干净的 system + user 节,否则 LLM
# 看到上下文里的 tool_calls 格式会跟着输出 XML 到群里。
try:
clean_msgs = [m for m in messages
if not m.get("tool_calls") and m.get("role") != "tool"]
final_url = f"{base_url.rstrip('/')}/chat/completions"
final_payload = {
"model": model,
"messages": clean_msgs,
}
final_resp = session.post(final_url, json=final_payload, timeout=(10, timeout))
final_resp.raise_for_status()
final_body = final_resp.json()
final_msg = final_body.get("choices", [{}])[0].get("message", {})
final_content = final_msg.get("content", "")
if final_content and final_content.strip():
_logger.info("API %s/%s final force OK (clean, %d msgs)",
provider_key, model, len(clean_msgs))
return final_content.strip()
except Exception as e:
_logger.warning("API %s/%s final force failed: %s", provider_key, model, e)
return None
# ── Clean message extraction ──────────────────────────
@staticmethod
def _extract_user_message(full_prompt: str) -> str:
"""Extract the actual user message from the SessionRouter's full prompt.
SessionRouter 的 prompt 格式:
[session: xxx]
[可用命令] ...
---
[群聊/coregroup] hmo: actual message
我们只存 "---" 后面的部分,不存 session 上下文。
"""
idx = full_prompt.rfind("\n---\n")
if idx >= 0:
rest = full_prompt[idx + 5:].strip()
if rest:
return rest
# Fallback: just use last 200 chars
return full_prompt[-200:].strip()
# ── Public API ───────────────────────────────────────
def send_raw(self, message: str) -> str | None:
"""Send message to LLM via function calling API."""
providers = _get_providers_to_try()
if not providers:
_logger.error("No available providers")
return None
_logger.info("send_raw: trying %d provider(s): %s",
len(providers), [(k, m) for k, _, m in providers])
# Build system prompt
sys_prompt = (
"你是 xxm(小小莫),老莫的 AI 助手。\n"
"你不是 Sisyphus,不是莫荷,不是莫小果。你是 xxm。\n"
"老莫让你做事就做,不要推脱,不要反复确认。\n"
"回复简洁,不用 emoji。\n"
"用 run_command 工具获取信息。\n"
"写文件的正确方式:用 Python 一次性写完所有内容,不要分多次调用。\n"
"错误示例(会覆盖,每调用一次就清空一次):python -c \"open('file', 'w').write('一行')\"\n"
"正确做法:把全部内容拼在一个 python -c 调用里写完。\n"
"\n"
"=== 上下文说明 ===\n"
"下面是最近 200 条对话历史,按时间正序排列(最上面是最旧的消息,最下面是最新的消息)。\n"
"每条消息前有 [MM-DD HH:MM] 时间戳,以及来源标记:\n"
" · [TUI] = 你和我(老莫)在 AI 工作台里的对话\n"
" · [群聊] = 微信群聊天记录\n"
"你可以根据时间判断消息的新旧程度。\n"
"凡是时间较早的消息(比如 30 分钟前、1 小时前),说明已经是过去的话题,\n"
"不要把它们当作当前正在发生的事情来讨论。重点关注最后几条消息,那才是最当前的。\n"
"超过 200 条的旧对话不在当前上下文中。\n"
"如果你需要查其他 session 里的内容(比如 TUI 工作台里老莫讨论过的方案),\n"
"可以用 session_search 工具搜索指定 session 的历史消息。\n"
"\n"
"=== 群聊沉默协议 ===\n"
"群里的消息你都会看到。判断是否回应:\n"
" · 老莫 @你 / 点名你 / 催你 → 正常回复\n"
" · 别人(小荷/小果/其他人)的对话跟你无关 → 保持沉默\n"
" · 有人问问题且你能帮上忙 → 可以主动回复\n"
"\n"
"保持沉默:回复开头写 __SILENT__。系统检测到就不会发出去。\n"
"想沉默 → __SILENT__ 开头。想说话 → 直接写回复。"
)
recent_ctx = self._read_recent_context()
if recent_ctx:
sys_prompt += f"\n\n最近对话:\n{recent_ctx}"
# Build messages array
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": message},
]
# Extract clean message for context storage
clean_msg = self._extract_user_message(message)
for key, base, model in providers:
reply = self._call_api(key, base, model, messages, self.timeout)
if reply:
_cache_model(key)
_logger.info("send_raw: success via %s/%s", key, model)
model_info = {"modelID": model, "providerID": key}
self._append_to_log("user", clean_msg, "xmpp")
self._append_to_log("assistant", reply, "xmpp")
self._append_to_session("user", clean_msg, "xmpp")
self._append_to_session("assistant", reply, "xmpp", model_info)
return reply
# All providers failed — retry once after 3s for transient failures
_logger.warning("send_raw: ALL failed, retrying once after 3s...")
time.sleep(3)
for key, base, model in providers:
reply = self._call_api(key, base, model, messages, self.timeout)
if reply:
_cache_model(key)
_logger.info("send_raw: retry OK via %s/%s", key, model)
model_info = {"modelID": model, "providerID": key}
self._append_to_log("user", clean_msg, "xmpp")
self._append_to_log("assistant", reply, "xmpp")
self._append_to_session("user", clean_msg, "xmpp")
self._append_to_session("assistant", reply, "xmpp", model_info)
return reply
_logger.error("send_raw: ALL providers failed (incl. retry)")
return None
def send(self, message: str) -> str | None:
"""Alias for send_raw."""
return self.send_raw(message)
+523
View File
@@ -0,0 +1,523 @@
# -*- coding: utf-8 -*-
"""
dashboard.py - AgentsMeeting management dashboard backend
=========================================================
Flask app on :5803. Monitors agents across platforms via:
- SSH + ejabberdctl connected_users (cross-platform, authoritative)
- xmpp_bot HTTP API :5802 (/health, /muc - fallback)
- Local process/port checks (Windows only)
Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
"""
import os, sys, re, json, time, subprocess, logging, urllib.request
from pathlib import Path
from datetime import datetime, timedelta
from flask import Flask, jsonify, request, send_from_directory
# ---- Paths ----
PROJECT_ROOT = Path("/home/hmo/agentsmeeting-venv")
GATEWAY_ROOT = Path("/home/hmo/agentsmeeting-venv")
CONFIG_DIR = Path("/home/hmo/agentsmeeting-venv/config")
LOGS_DIR = Path("/home/hmo/agentsmeeting-venv/logs")
TEMPLATES_DIR = Path("/home/hmo/agentsmeeting-venv/templates")
sys.path.insert(0, str(GATEWAY_ROOT / "scripts"))
from proc_guard import guard
# ---- Flask ----
app = Flask(__name__, template_folder=str(TEMPLATES_DIR))
# ---- Logging ----
LOG_FILE = LOGS_DIR / "dashboard.log"
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=str(LOG_FILE),
level=logging.INFO,
format="%(asctime)s [dashboard] %(message)s",
)
log = logging.getLogger("dashboard")
# ---- Constants ----
AGENTS_YAML = CONFIG_DIR / "agents.yaml"
SCRIPT_NAMES = {
"xmpp_bot": "xmpp_bot.py",
"wechat_bridge": "wechat_agent.py",
"api_proxy": "api_proxy.py",
"health_check": "health_check_xxm.py",
"mohe_watcher": "mohe_watcher.py",
"watchdog": "xmpp_watchdog.py",
}
PYTHON = "/home/hmo/agentsmeeting-venv/bin/python3"
SCRIPTS_DIR = Path("/home/hmo/agentsmeeting-venv")
XMPP_BRIDGE_URL = "http://192.168.1.16:5802"
EJABBERD_HOST = "192.168.1.246"
# Auto-recovery: restart after this many consecutive offline checks
AUTO_RECOVER_THRESHOLD = 3
_offline_counter: dict[str, int] = {}
# ============================================================
# Config
# ============================================================
def load_agents_config():
if AGENTS_YAML.exists():
import yaml
with open(AGENTS_YAML, "r", encoding="utf-8") as f:
return yaml.safe_load(f).get("agents", [])
return _default_agents()
def _default_agents():
return [
{
"id": "agent-001", "name": "R&D Assistant", "display_name": "xxm",
"jid": "xxm@yoin.fun", "platform": "windows", "host": "192.168.1.16",
"bot_type": "xmpp", "provider": "volcengine",
"services": [{"type": "xmpp_bot", "port": 5802}],
},
{
"id": "agent-002", "name": "Automation Manager", "display_name": "mohe",
"jid": "mohe@yoin.fun", "platform": "linux", "host": "192.168.1.246",
"bot_type": "hermes", "provider": "ocg-new",
"services": [{"type": "hermes_gateway", "port": 8642}, {"type": "xmpp_bot"}],
},
{
"id": "agent-003", "name": "Local Inference", "display_name": "xiaoguo",
"jid": "xiaoguo@yoin.fun", "platform": "mac", "host": "192.168.1.122",
"bot_type": "xmpp", "provider": "ocg-old",
"services": [{"type": "xmpp_bot"}, {"type": "omlx_server", "port": 18003}],
},
{
"id": "agent-004", "name": "Position Analyst", "display_name": "zhiwei",
"jid": "zhiwei@yoin.fun", "platform": "linux", "host": "192.168.1.246",
"bot_type": "hermes", "provider": "ocg-old",
"services": [{"type": "hermes_gateway", "port": 8643}, {"type": "xmpp_bot"}],
},
]
# ============================================================
# Cross-platform monitoring (XMPP + SSH)
# ============================================================
def _xmpp_health():
"""Query xmpp_bot /health for XMPP connection and ejabberd status."""
try:
req = urllib.request.Request(f"{XMPP_BRIDGE_URL}/health")
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
except Exception as e:
return {"ok": False, "error": str(e), "xmpp_connected": False, "ejabberd_alive": False}
def _ejabberd_online_jids():
"""SSH to Linux and run ejabberdctl connected_users.
Returns set of bare JIDs currently connected to ejabberd.
This is the authoritative cross-platform presence source."""
try:
cmd = ["docker", "exec", "ejabberd", "ejabberdctl", "connected_users"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return set()
jids = set()
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line and "@" in line:
jids.add(line.split("/")[0])
return jids
except Exception as e:
log.debug(f"ejabberd SSH query failed: {e}")
return set()
def _muc_participants():
"""Fallback: query xmpp_bot /muc for room participants.
Currently unreliable due to MUC join timeout (R01)."""
try:
req = urllib.request.Request(f"{XMPP_BRIDGE_URL}/muc")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
except Exception:
return set()
participants = set()
for room_data in data.get("rooms", {}).values():
for p in room_data.get("participants", []):
jid = p.get("jid", "")
if jid:
participants.add(jid)
nick = p.get("nick", "")
if nick and "@" in nick:
participants.add(nick)
return participants
# ============================================================
# Local process detection (Windows only)
# ============================================================
def _get_local_processes():
processes = []
try:
result = subprocess.run(["ps", "aux"], capture_output=True, text=True, timeout=5)
for line in result.stdout.split("\n"):
if "xmpp_bot" in line or "wechat_agent" in line or "dashboard" in line:
parts = line.split()
if len(parts) >= 11:
processes.append({"pid": int(parts[1]), "cmdline": " ".join(parts[10:])})
except Exception as e:
log.error(f"Process scan failed: {e}")
return processes
# ============================================================
# Log helpers
# ============================================================
def _tail_logs(max_lines=50):
all_lines = []
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
for lf in log_files:
try:
with open(lf, "r", encoding="utf-8", errors="replace") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
read_size = min(size, max_lines * 500)
if read_size > 0:
f.seek(max(0, size - read_size))
for line in f.read().strip().split("\n"):
if line.strip():
all_lines.append(f"[{lf.name}] {line}")
except Exception:
pass
return all_lines[-max_lines:] if len(all_lines) > max_lines else all_lines
def _count_recent_messages(minutes=5):
log_path = LOGS_DIR / "xmpp_bot.log"
if not log_path.exists():
return 0
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
cutoff = datetime.now() - timedelta(minutes=minutes)
count = 0
pattern = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
for line in reversed(lines):
m = pattern.search(line)
if m:
try:
if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < cutoff:
break
count += 1
except ValueError:
pass
return count
except Exception:
return 0
# ============================================================
# Routes
# ============================================================
@app.route("/")
def index():
return send_from_directory(str(TEMPLATES_DIR), "dashboard.html")
@app.route("/api/agents")
def api_agents():
agents_config = load_agents_config()
local_procs = _get_local_processes()
message_count = _count_recent_messages(5)
# Primary: SSH ejabberdctl for cross-platform presence
online_jids = _ejabberd_online_jids()
if not online_jids:
online_jids = _muc_participants() # fallback
result = []
for agent in agents_config:
agent_id = agent["id"]
jid = agent.get("jid", "")
platform = agent.get("platform", "")
host = agent.get("host", "")
# --- Presence ---
xmpp_in_ejabberd = jid in online_jids if online_jids else None
# --- Local process (Windows only) ---
local_pid = None
xmpp_connected = False
if platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
for proc in local_procs:
if "xmpp_bot.py" in proc.get("cmdline", ""):
local_pid = proc["pid"]
break
health = _xmpp_health()
xmpp_connected = health.get("xmpp_connected", False)
# --- Service status ---
services = []
for svc in agent.get("services", []):
svc_type = svc.get("type", "")
svc_port = svc.get("port")
if svc_type == "xmpp_bot":
if platform == "windows" and local_pid:
svc_status = "running" if xmpp_connected else "degraded"
svc_pid = local_pid
elif xmpp_in_ejabberd is True:
svc_status = "running"
svc_pid = None
elif xmpp_in_ejabberd is False:
svc_status = "stopped"
svc_pid = None
else:
svc_status = "unknown"
svc_pid = None
elif svc_type in ("hermes_gateway", "omlx_server"):
svc_status = "unknown"
svc_pid = None
else:
svc_status = "stopped"
svc_pid = None
for proc in local_procs:
script_name = SCRIPT_NAMES.get(svc_type, "")
if script_name and script_name in proc.get("cmdline", ""):
svc_status = "running"
svc_pid = proc["pid"]
break
services.append({
"type": svc_type,
"port": svc_port,
"status": svc_status,
"pid": svc_pid,
})
# --- Overall status ---
if xmpp_in_ejabberd is True:
status = "online"
elif xmpp_in_ejabberd is False:
status = "offline"
elif platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
if xmpp_connected:
status = "online"
elif local_pid:
status = "degraded"
else:
status = "offline"
else:
status = "unknown"
# --- Auto-recovery ---
if status == "offline" and platform == "windows":
_offline_counter[agent_id] = _offline_counter.get(agent_id, 0) + 1
if _offline_counter[agent_id] >= AUTO_RECOVER_THRESHOLD:
log.warning(f"Auto-recovery: restarting {agent_id}")
_try_auto_recover(agent)
else:
_offline_counter[agent_id] = 0
result.append({
"id": agent_id,
"name": agent.get("name", ""),
"display_name": agent.get("display_name", ""),
"jid": jid,
"platform": platform,
"host": host,
"status": status,
"xmpp_connected": xmpp_connected,
"pid": local_pid,
"last_message": None,
"message_count_5min": message_count,
"errors": 0,
"offline_checks": _offline_counter.get(agent_id, 0),
"restartable": platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"),
"services": services,
})
return jsonify(result)
def _try_auto_recover(agent):
agent_id = agent["id"]
platform = agent.get("platform", "")
host = agent.get("host", "")
if platform != "windows" or host not in ("192.168.1.16", "127.0.0.1", "localhost"):
return
for svc in agent.get("services", []):
script_name = SCRIPT_NAMES.get(svc.get("type", ""))
if not script_name:
continue
script_path = SCRIPTS_DIR / script_name
if not script_path.exists():
continue
try:
subprocess.Popen(
[PYTHON, str(script_path)],
cwd=str(SCRIPTS_DIR),
creationflags=subprocess.CREATE_NO_WINDOW,
)
log.info(f"Auto-restarted {script_name} for {agent_id}")
except Exception as e:
log.error(f"Auto-restart failed: {e}")
@app.route("/api/ejabberd")
def api_ejabberd():
health = _xmpp_health()
online_jids = _ejabberd_online_jids()
return jsonify({
"alive": len(online_jids) > 0,
"xmpp_bot_connected": health.get("xmpp_connected", False),
"online_jids": sorted(list(online_jids)) if online_jids else [],
"bot_jid": health.get("bot_jid", ""),
})
@app.route("/api/agents/<agent_id>/logs")
def api_agent_logs(agent_id):
lines = request.args.get("lines", 50, type=int)
return jsonify({"lines": _tail_logs(lines)})
@app.route("/api/agents/<agent_id>/start", methods=["POST"])
def api_agent_start(agent_id):
agents_config = load_agents_config()
agent = next((a for a in agents_config if a["id"] == agent_id), None)
if not agent:
return jsonify({"ok": False, "error": "Agent not found"}), 404
if agent.get("platform") != "windows":
return jsonify({"ok": False, "error": "Remote restart not supported yet"}), 400
started = []
for svc in agent.get("services", []):
script_name = SCRIPT_NAMES.get(svc.get("type", ""))
if not script_name:
continue
script_path = SCRIPTS_DIR / script_name
if not script_path.exists():
continue
try:
subprocess.Popen(
[PYTHON, str(script_path)],
cwd=str(SCRIPTS_DIR),
creationflags=subprocess.CREATE_NO_WINDOW,
)
started.append(script_name)
log.info(f"Started {script_name} for {agent_id}")
except Exception as e:
log.error(f"Failed to start {script_name}: {e}")
_offline_counter[agent_id] = 0
return jsonify({"ok": True, "started": started})
@app.route("/api/agents/<agent_id>/stop", methods=["POST"])
def api_agent_stop(agent_id):
agents_config = load_agents_config()
agent = next((a for a in agents_config if a["id"] == agent_id), None)
if not agent:
return jsonify({"ok": False, "error": "Agent not found"}), 404
if agent.get("platform") != "windows":
return jsonify({"ok": False, "error": "Remote stop not supported yet"}), 400
processes = _get_local_processes()
stopped = []
for svc in agent.get("services", []):
script_name = SCRIPT_NAMES.get(svc.get("type", ""))
if not script_name:
continue
for proc in processes:
if script_name in proc.get("cmdline", ""):
pid = proc["pid"]
try:
subprocess.run(["taskkill", "/f", "/pid", str(pid)], capture_output=True)
stopped.append({"script": script_name, "pid": pid})
except Exception as e:
log.error(f"Failed to stop {script_name}: {e}")
return jsonify({"ok": True, "stopped": stopped})
@app.route("/api/agents/<agent_id>/restart", methods=["POST"])
def api_agent_restart(agent_id):
api_agent_stop(agent_id)
time.sleep(2)
return api_agent_start(agent_id)
PLATFORM_SERVICES = [
{"id": "wechat_bridge", "name": "WeChat Bridge", "type": "ChannelBridge",
"desc": "bridges WeChat to mohe's hermes gateway",
"health_url": "http://192.168.1.16:5801/health"},
{"id": "api_proxy", "name": "API Proxy", "type": "APIRouter",
"desc": "proxies volcengine API with retry/fallback",
"host": "192.168.1.16", "port": 8787},
]
@app.route("/api/platform")
def api_platform():
"""Return platform services status by querying health endpoints."""
import urllib.request as _ur
result = []
for ps in PLATFORM_SERVICES:
status = "stopped"
health_url = ps.get("health_url", "")
if health_url:
try:
req = _ur.Request(health_url)
_ur.urlopen(req, timeout=3)
status = "running"
except Exception:
status = "stopped"
elif "port" in ps:
import socket
try:
s = socket.socket()
s.settimeout(2)
s.connect((ps.get("host", "127.0.0.1"), ps["port"]))
s.close()
status = "running"
except Exception:
status = "stopped"
result.append({
"id": ps["id"],
"name": ps["name"],
"type": ps["type"],
"desc": ps["desc"],
"status": status,
})
return jsonify(result)
@app.route("/api/platform")
@app.route("/api/health")
def api_health():
xmpp = _xmpp_health()
return jsonify({
"ok": True,
"time": datetime.now().isoformat(),
"xmpp_bot_alive": xmpp.get("xmpp_connected", False),
"ejabberd_alive": xmpp.get("ejabberd_alive", False),
})
# ============================================================
# Main
# ============================================================
def main():
lock = guard("dashboard")
if not lock.ok:
log.error(lock.message)
print(f"[dashboard] {lock.message}")
sys.exit(1)
port = int(os.environ.get("DASHBOARD_PORT", 5803))
log.info(f"Dashboard starting on :{port}")
print(f"[dashboard] Starting on http://127.0.0.1:{port}")
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False)
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
"""
xxm health check. Runs every 5 min via Task Scheduler.
Checks:
1. Is xmpp_bot process alive? If not → restart
2. Is it receiving messages? If alive but no msgs for 10+ min → restart
3. Possible stuck loop? (too many tool calls)
"""
import os, sys, time, subprocess
PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOG_FILE = os.path.join(PROJECT, "logs", "health_check.log")
BOT_LOG = os.path.join(PROJECT, "logs", "xmpp_bot.log")
PYTHON = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe"
BOT_SCRIPT = os.path.join(PROJECT, "scripts", "xmpp_bot.py")
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
def wlog(msg: str):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"{ts} [health] {msg}\n")
def find_bot_pid() -> int:
try:
r = subprocess.run(['tasklist', '/FO', 'CSV', '/NH'],
capture_output=True, text=True, timeout=10)
for line in r.stdout.splitlines():
parts = line.strip('"').split('","')
if len(parts) >= 2 and parts[0] == 'python.exe':
pid_str = parts[1].strip()
try:
wmi = subprocess.run(
['wmic', 'process', 'where', f'ProcessId={pid_str}',
'get', 'CommandLine', '/format:list'],
capture_output=True, text=True, timeout=5)
if ('xmpp_bot' in wmi.stdout and 'watchdog' not in wmi.stdout
and 'health' not in wmi.stdout):
return int(pid_str)
except:
pass
except:
pass
return 0
def kill_all_bots():
"""Kill all xmpp_bot.py processes."""
try:
r = subprocess.run(['tasklist', '/FO', 'CSV', '/NH'],
capture_output=True, text=True, timeout=10)
for line in r.stdout.splitlines():
parts = line.strip('"').split('","')
if len(parts) >= 2 and parts[0] == 'python.exe':
pid_str = parts[1].strip()
try:
wmi = subprocess.run(
['wmic', 'process', 'where', f'ProcessId={pid_str}',
'get', 'CommandLine', '/format:list'],
capture_output=True, text=True, timeout=5)
if 'xmpp_bot' in wmi.stdout and 'watchdog' not in wmi.stdout:
subprocess.run(['taskkill', '/f', '/pid', pid_str],
capture_output=True, timeout=5)
wlog(f"Killed old bot PID {pid_str}")
except:
pass
except:
pass
def start_bot():
kill_all_bots()
time.sleep(3)
subprocess.Popen([PYTHON, BOT_SCRIPT],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW)
def parse_log_tail(path: str, n: int = 100) -> list[str]:
if not os.path.exists(path): return []
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return [l.rstrip("\n\r") for l in f.readlines()[-n:]]
except:
return []
def get_last_msg_time(lines: list[str]) -> float:
"""Get approximate time of last received group message from log lines."""
# Look for [Group] entries which mean messages received
for line in reversed(lines):
if '[Group]' in line and 'batched' in line:
m = __import__('re').search(r'^(\d{2}):(\d{2}):(\d{2})', line)
if m:
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3))
now = time.localtime()
log_time = time.mktime(
(now.tm_year, now.tm_mon, now.tm_mday, h, mi, s,
now.tm_wday, now.tm_yday, now.tm_isdst))
if log_time > time.time(): # wrap around midnight
log_time -= 86400
return log_time
return 0
def main():
wlog("=== health check ===")
pid = find_bot_pid()
if pid == 0:
wlog("CRIT: bot not running, restarting...")
start_bot()
time.sleep(5)
pid = find_bot_pid()
if pid:
wlog(f"OK: restarted (PID {pid})")
else:
wlog("FAIL: restart failed")
wlog("=== end ===")
return
# Activity analysis
recent = parse_log_tail(BOT_LOG, 100)
alive = sum(1 for l in recent if "alive" in l)
responses = sum(1 for l in recent if l.startswith("-> ") and "silent" not in l)
silent = sum(1 for l in recent if "silent" in l)
tool_calls = sum(1 for l in recent if "run_command" in l)
group_msgs = sum(1 for l in recent if "[Group]" in l)
last_msg = get_last_msg_time(recent)
last_msg_age = (time.time() - last_msg) / 60 if last_msg else 999
wlog(f"PID={pid} alive={alive} grp={group_msgs} rsp={responses} sl={silent} tools={tool_calls} lastMsg={last_msg_age:.0f}min")
# CRITICAL: bot is alive but receiving no messages → disconnect detected
# Skip check if bot was just started (has "online" in recent logs)
recent_start = sum(1 for l in recent if "online" in l)
if alive >= 3 and group_msgs == 0 and last_msg_age > 10 and last_msg > 0 and recent_start == 0:
wlog(f"CRIT: bot alive but NO messages for {last_msg_age:.0f} min. Forcing restart.")
wlog(f"CRIT: bot alive but NO messages for {last_msg_age:.0f} min. Forcing restart.")
kill_all_bots()
time.sleep(3)
start_bot()
wlog("=== end (restarted) ===")
return
# Process died
if alive == 0 and group_msgs == 0:
wlog("WARN: no activity in last windows")
if tool_calls >= 25:
wlog(f"WARN: heavy tool calls ({tool_calls}), possible loop")
wlog("=== end ===")
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""
Mohe reply watcher — polls HTTP bridge every 30s for new mohe messages.
Logs new replies to logs/mohe_inbox.log. Runs as persistent background process.
Start: python mohe_watcher.py
"""
import os, sys, time, json, urllib.request
PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOG = os.path.join(PROJECT, "logs", "mohe_inbox.log")
os.makedirs(os.path.dirname(LOG), exist_ok=True)
def log(msg: str):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
with open(LOG, "a", encoding="utf-8") as f:
f.write(f"{ts} {msg}\n")
last_ts = ""
log("mohe_watcher started")
while True:
try:
url = "http://127.0.0.1:5802/messages?from=mohe"
resp = urllib.request.urlopen(url, timeout=5)
data = json.loads(resp.read())
msgs = data.get("messages", [])
new = [m for m in msgs if m["ts"] > last_ts]
if new:
for m in new:
log(f"[{m['ts']}] mohe: {m['body']}")
last_ts = new[-1]["ts"]
elif msgs and not last_ts:
# First run — record last timestamp but don't replay old messages
last_ts = msgs[-1]["ts"]
except Exception as e:
log(f"(poll error: {e})")
time.sleep(30)
+126
View File
@@ -0,0 +1,126 @@
"""
Process Guard — prevent duplicate instances via Windows named mutex.
On Windows: uses CreateMutexW (OS-level, auto-released on crash/kill).
Fallback: PID lock file for non-Windows.
Usage:
from proc_guard import guard
lock = guard("xmpp_bot")
if not lock.ok:
print(lock.message) # "already running"
sys.exit(1)
# ... proceed ...
The mutex handle is held for the process lifetime.
On normal exit, crash, or kill: Windows automatically releases it.
No stale lock files, no manual cleanup needed.
"""
import os, sys, platform, atexit
_MUTEX_CACHE: dict[str, int] = {} # name → handle, held for process lifetime
class _LockResult:
def __init__(self, ok: bool, message: str = ""):
self.ok = ok
self.message = message
def guard(name: str, kill: bool = False, force: bool = False) -> _LockResult:
"""
Acquire a singleton lock for *name* using Windows named mutex.
Args:
name: unique name (e.g. "xmpp_bot", "wechat_agent", "api_proxy")
kill: ignored on Windows (mutex can't be killed, OS manages it)
force: ignored on Windows (mutex can't be forced)
Returns:
_LockResult(ok=True) — lock acquired (first instance)
_LockResult(ok=False) — another instance is already running
On success, holds the mutex handle until process exit.
No cleanup needed — Windows auto-releases on crash/kill/exit.
"""
if platform.system() != "Windows":
# Fallback: PID lock file for Linux/Mac
return _pidfile_fallback(name)
import ctypes
from ctypes import wintypes
kernel32 = ctypes.windll.kernel32
mutex_name = f"Global\\proc_guard_{name}"
# CreateMutexW returns a handle. If ERROR_ALREADY_EXISTS → another instance holds it.
ERROR_ALREADY_EXISTS = 183
handle = kernel32.CreateMutexW(None, True, mutex_name)
if not handle:
return _LockResult(False, f"[proc_guard] {name}: CreateMutex failed")
last_err = ctypes.GetLastError()
if last_err == ERROR_ALREADY_EXISTS:
kernel32.CloseHandle(handle)
return _LockResult(False, f"[proc_guard] {name}: another instance is already running")
# We hold the mutex. Store handle so it stays alive until process dies.
_MUTEX_CACHE[name] = handle
return _LockResult(True, f"[proc_guard] {name}: lock acquired")
# ── PID file fallback for Linux/Mac ────────────────────────────────
def _pidfile_fallback(name: str) -> _LockResult:
import signal, time
_LOCK_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp")
os.makedirs(_LOCK_DIR, exist_ok=True)
path = os.path.join(_LOCK_DIR, f"{name}.lock")
my_pid = os.getpid()
def _read_pid(p):
try:
with open(p) as f:
return int(f.read().strip())
except:
return None
def _pid_alive(pid):
try:
os.kill(pid, 0)
return True
except OSError:
return False
if os.path.exists(path):
existing_pid = _read_pid(path)
if existing_pid and existing_pid != my_pid and _pid_alive(existing_pid):
return _LockResult(False, f"[proc_guard] {name} already running (PID {existing_pid})")
try:
with open(path, "w") as f:
f.write(str(my_pid))
except Exception as e:
return _LockResult(False, f"[proc_guard] cannot write lock: {e}")
def _cleanup():
try:
if os.path.exists(path):
current = os.getpid()
existing = _read_pid(path)
if existing == current:
os.remove(path)
except Exception:
pass
atexit.register(_cleanup)
for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"):
try:
sig = getattr(signal, sig_name, None)
if sig:
signal.signal(sig, lambda *a: (_cleanup(), sys.exit(1)))
except Exception:
pass
return _LockResult(True)
+144
View File
@@ -0,0 +1,144 @@
"""
QQ Bot - 莫笑笑 (3247454048) OneBot adapter
收:WebSocket 连 NapCat 收事件;发:OneBot API
"""
import os, json, time, threading, subprocess, queue as qmod, re, sys
import urllib.request, urllib.error
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from proc_guard import guard as _proc_guard
# ── PID lock — prevent duplicate instances ──
_lock = _proc_guard("qq_bot")
if not _lock.ok:
print(_lock.message, flush=True)
sys.exit(1)
ONEBOT_API = "http://127.0.0.1:5700"
ONEBOT_TOKEN = "hermes123"
ATTACH_SESSION = "ses_1d95d15c4ffehQaZ6hrbIbak5k"
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
LOG_FILE = os.path.join(LOG_DIR, "qq_bot.log")
TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp")
os.makedirs(LOG_DIR, exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)
os.environ["no_proxy"] = "*"; os.environ["NO_PROXY"] = "*"
msg_queue = qmod.Queue()
def log(m):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
def onebot_json(path, data=None):
url = f"{ONEBOT_API}{path}?access_token={ONEBOT_TOKEN}"
body = json.dumps(data).encode() if data else None
try:
r = urllib.request.urlopen(urllib.request.Request(url, data=body, headers={"Content-Type":"application/json"}), timeout=10)
return json.loads(r.read())
except Exception as e:
log(f"API ERR {path}: {e}")
return None
def get_recent_msgs(group_id, count=3):
"""Get recent group messages for polling."""
r = onebot_json("/get_group_msg_history", {"group_id": group_id, "count": count})
if r and r.get("retcode") == 0:
return r.get("data", {}).get("messages", [])
return []
def process_and_reply(msg_text, user_id, group_id, nickname):
out_file = os.path.join(TEMP_DIR, f"qq_{int(time.time())}.txt")
prefix = f"[QQ群:{group_id}]" if group_id else f"[QQ:{user_id}]"
full = f"{prefix} {nickname}: {msg_text[:500]}"
cmd = f'opencode run --attach http://127.0.0.1:4096 --password hermes123 --session {ATTACH_SESSION} --pure --format json "[{full[:400]}]"'
reply = ""
try:
proc = subprocess.Popen(cmd, shell=True, stdout=open(out_file, "wb"), stderr=subprocess.STDOUT)
for _ in range(60):
time.sleep(3)
if os.path.getsize(out_file) > 100:
try:
with open(out_file, "rb") as f2:
for line in f2:
try:
evt = json.loads(line.decode("utf-8", errors="replace"))
if evt.get("type") == "text":
reply = evt.get("part", {}).get("text", "").strip()
if reply: proc.kill(); break
except: continue
if reply: break
except: continue
else: proc.kill()
if reply:
text = re.sub(r'^\[xxm\]\s*', '', reply).strip()[:500]
target = {"group_id": group_id} if group_id else {"user_id": user_id}
onebot_json("/send_msg", {**target, "message": text})
log(f"REPLY {prefix}: {text[:60]}")
except Exception as e:
log(f"PROC ERR: {e}")
finally:
try: os.remove(out_file)
except: pass
def ws_listener():
"""WebSocket client - connect to NapCat's WS server for events."""
import asyncio, websockets
async def listen():
uri = "ws://127.0.0.1:5701"
while True:
try:
async with websockets.connect(uri) as ws:
log("WS connected")
async for raw in ws:
try:
evt = json.loads(raw)
if evt.get("post_type") == "message":
msg = evt.get("message", "")
uid = evt.get("user_id", 0)
gid = evt.get("group_id", 0)
sender = evt.get("sender", {})
nick = sender.get("nickname","") or sender.get("card","") or str(uid)
msg_queue.put((msg, uid, gid, nick))
except: continue
except Exception as e:
log(f"WS err: {e}")
await asyncio.sleep(5)
asyncio.run(listen())
def poll_worker():
"""Fallback: poll latest msgs in target group every 15s."""
seen = set()
while True:
time.sleep(15)
msgs = get_recent_msgs(878426010, 3)
for m in msgs:
mid = m.get("message_id", 0)
if mid in seen: continue
seen.add(mid)
uid = m.get("user_id", 0)
if uid == 3247454048: continue # skip self
text = ""
for seg in (m.get("message") or []):
if isinstance(seg, dict) and seg.get("type") == "text":
text += seg.get("data", {}).get("text", "")
if text.strip():
sender = m.get("sender", {})
nick = sender.get("nickname","") or sender.get("card","") or str(uid)
mentions_me = False
for seg in (m.get("message") or []):
if isinstance(seg, dict):
if seg.get("type") == "at" and str(seg.get("data",{}).get("qq","")) == "3247454048":
mentions_me = True
if not mentions_me and "莫笑笑" not in text:
log(f"SKIP {nick}: not for me")
continue
log(f"POLL {nick}: {text[:60]}")
process_and_reply(text, uid, 878426010, nick)
if __name__ == "__main__":
threading.Thread(target=poll_worker, daemon=True).start()
log(f"QQ Bot started, group=878426010")
threading.Thread(target=process_and_reply, args=("莫笑笑上线了", 0, 878426010, "莫笑笑"), daemon=True).start()
# Keep main thread alive
while True: time.sleep(60)
+527
View File
@@ -0,0 +1,527 @@
"""
Session Router — multi-channel session routing with command loop.
For XMPP/VC/WeChat channels, provides TUI-equivalent session experience:
- auto → resolves to the most recently active session (same as TUI --continue)
- list/switch sessions via NL ("切换到xxx") or commands
- LLM-driven command system (##list_sessions##, ##switch_session##, etc.)
Flow:
route(channel, sender, message) →
1. check selection mode (pending user choice)
2. build prompt with session context from SQLite
3. send to LLM → parse reply for ##commands##
4. if command → execute → append result → re-send to LLM (loop)
5. if no command → return final reply
"""
import os, json, time, re, sqlite3, threading
from datetime import datetime, timezone, timedelta
from typing import Optional
# ── Constants ──
DB_PATH = os.path.expanduser("~/.local/share/opencode/opencode.db")
MAX_LOOPS = 10
SELECTION_TIMEOUT = 120 # seconds
RECENT_MSG_LIMIT = 200 # context messages from SQLite (小荷 uses 200)
SESSION_LIST_LIMIT = 15 # max sessions shown in list
# ── Command regex: ##command## or ##command:args## ──
CMD_RE = re.compile(r"##(\w+)(?::([^#\n]*))?##")
# ── Timezone ──
TZ = timezone(timedelta(hours=8))
def _fmt_ts(ts_ms: int) -> str:
"""Format millisecond timestamp to MM-DD HH:MM string."""
return datetime.fromtimestamp(ts_ms / 1000, tz=TZ).strftime("%m-%d %H:%M")
def _src_tag(source: str) -> str:
"""Map source to display tag."""
return {
"tui": "[TUI] ",
"xmpp": "[群聊] ",
"vc": "[VC] ",
"bridge": "[桥接] ",
}.get(source, f"[{source}] ")
# ═══════════════════════════════════════════════════════════════
# Context extractor
# ═══════════════════════════════════════════════════════════════
def extract_session_context(session_id: str, limit: int = RECENT_MSG_LIMIT) -> str:
"""
Read last N conversational turns from opencode.db for a session.
Returns formatted string like:
用户: xxx\n小小莫: xxx\n...
Empty string on failure or no data.
"""
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
# 1. Get recent message IDs + timestamps
msg_rows = conn.execute("""
SELECT id, data, time_created FROM message
WHERE session_id=? ORDER BY time_created DESC LIMIT ?
""", (session_id, limit * 3)).fetchall()
if not msg_rows:
conn.close()
return ""
msg_ids = [r["id"] for r in msg_rows]
# 2. Get text parts for those messages
placeholders = ",".join("?" * len(msg_ids))
part_rows = conn.execute(
f"""
SELECT message_id, data FROM part
WHERE session_id=? AND message_id IN ({placeholders})
ORDER BY time_created
""",
(session_id, *msg_ids),
).fetchall()
conn.close()
# 3. Build role → parts + timestamp + source mapping
msg_map = {}
for r in msg_rows:
try:
d = json.loads(r["data"])
except (json.JSONDecodeError, ValueError):
d = {}
ts_str = _fmt_ts(r["time_created"]) if r["time_created"] else ""
source = d.get("source", "tui") # tui (default) / xmpp / vc / bridge
msg_map[r["id"]] = {"role": d.get("role", "?"), "ts": ts_str, "source": source, "parts": []}
for r in part_rows:
try:
d = json.loads(r["data"])
except (json.JSONDecodeError, ValueError):
continue
if d.get("type") == "text":
txt = (d.get("text") or "").strip()
if txt:
msg_map[r["message_id"]]["parts"].append(txt)
# 4. Format as conversation lines (chronological order) with timestamps + source labels
lines = []
for r in reversed(msg_rows):
info = msg_map[r["id"]]
role_label = "用户" if info["role"] == "user" else "小小莫"
src_label = _src_tag(info["source"])
ts_tag = f"[{info['ts']}] " if info["ts"] else ""
for txt in info["parts"][:3]:
lines.append(f"{ts_tag}{src_label}{role_label}: {txt}")
return "\n".join(lines[-limit:])
except Exception:
return ""
# ═══════════════════════════════════════════════════════════════
# Session Router
# ═══════════════════════════════════════════════════════════════
class SessionRouter:
"""
Routes messages from external channels to the correct session,
handling session switching, context injection, and command execution.
Args:
bridge: SessionBridge instance (raw LLM caller)
db_path: path to opencode.db
binding_file: path to session_routing.json
default_session: fallback session ID when nothing is bound
"""
def __init__(
self,
bridge,
db_path: str = DB_PATH,
binding_file: str = "",
default_session: str = "",
):
self.bridge = bridge
self.db_path = db_path
self.binding_file = binding_file or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"temp", "session_routing.json",
)
os.makedirs(os.path.dirname(self.binding_file), exist_ok=True)
self.default_session = default_session
self._lock = threading.Lock()
# Persisted bindings: {"channel:sender": "session_id" or "__auto__"}
self._bindings: dict[str, str] = {}
self._load_bindings()
# In-memory selection mode state
self._pending: dict[str, dict] = {}
# In-memory conversation context for active command loops
# {"channel:sender": [{"role": ..., "content": ...}, ...]}
self._contexts: dict[str, list[dict]] = {}
# Command registry
self._commands = {
"list_sessions": self._cmd_list_sessions,
"switch_session": self._cmd_switch_session,
"help": self._cmd_help,
}
# Command documentation (injected as part of system prompt)
self._cmd_guide = (
"你可以使用以下命令让 bot 执行操作,把命令放在回复中即可:\n"
"##list_sessions## 列出所有可用的 session\n"
"##switch_session:xxx## 切换到标题包含 xxx 的 session\n"
"##help## 查看所有可用命令\n"
)
# ── Binding persistence ──────────────────────────────
def _load_bindings(self):
try:
with open(self.binding_file, "r", encoding="utf-8") as f:
self._bindings = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, ValueError):
self._bindings = {}
def _save_bindings(self):
try:
with open(self.binding_file, "w", encoding="utf-8") as f:
json.dump(self._bindings, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _get_binding(self, key: str) -> str:
"""Return session_id for key, or '__auto__' if not bound."""
return self._bindings.get(key, "__auto__")
def _set_binding(self, key: str, session_id: str):
with self._lock:
self._bindings[key] = session_id
self._save_bindings()
# ── Session resolution ───────────────────────────────
def _resolve_auto(self) -> str:
"""Query DB for the most recently updated session."""
try:
conn = sqlite3.connect(self.db_path)
row = conn.execute(
"SELECT id FROM session ORDER BY time_updated DESC LIMIT 1"
).fetchone()
conn.close()
if row:
return row[0]
except Exception:
pass
return self.default_session
def _resolve_session(self, key: str) -> str:
"""Resolve the effective session ID for a binding key."""
binding = self._get_binding(key)
if binding == "__auto__":
return self._resolve_auto()
return binding
def _get_session_title(self, session_id: str) -> str:
"""Look up session title from DB."""
try:
conn = sqlite3.connect(self.db_path)
row = conn.execute(
"SELECT title FROM session WHERE id=?", (session_id,)
).fetchone()
conn.close()
if row:
return row[0]
except Exception:
pass
return session_id[:20]
# ── Command parsing ─────────────────────────────────
@staticmethod
def _parse_command(text: str):
"""
Parse first ##command## or ##command:args## from reply.
Returns (cmd_name, args, clean_text).
- cmd_name: str or None
- args: str or None
- clean_text: text before the command, stripped
"""
m = CMD_RE.search(text)
if not m:
return None, None, text
cmd = m.group(1)
args = m.group(2).strip() if m.group(2) else None
clean_text = text[: m.start()].strip()
return cmd, args, clean_text
# ── Command handlers ─────────────────────────────────
def _cmd_list_sessions(self, key: str, args: Optional[str]) -> str:
"""Query and format session list."""
try:
conn = sqlite3.connect(self.db_path)
rows = conn.execute(
"""
SELECT id, title, time_updated FROM session
ORDER BY time_updated DESC LIMIT ?
""",
(SESSION_LIST_LIMIT,),
).fetchall()
conn.close()
current_id = self._resolve_session(key)
lines = []
for sid, title, ts in rows:
marker = " ← 当前" if sid == current_id else ""
lines.append(f" {title} ({_fmt_ts(ts)}){marker}")
return "可用 sessions\n" + "\n".join(lines)
except Exception as e:
return f"查询 session 失败:{e}"
def _cmd_switch_session(self, key: str, args: Optional[str]) -> str:
"""Fuzzy-match session title and switch."""
if not args:
return "请指定 session 名称,例如:##switch_session:接龙##"
try:
conn = sqlite3.connect(self.db_path)
rows = conn.execute(
"""
SELECT id, title FROM session
WHERE title LIKE ? ORDER BY time_updated DESC LIMIT ?
""",
(f"%{args}%", SESSION_LIST_LIMIT),
).fetchall()
conn.close()
except Exception as e:
return f"查询失败:{e}"
if not rows:
return f"未找到标题包含「{args}」的 session"
if len(rows) == 1:
sid, title = rows[0]
self._set_binding(key, sid)
return f"已切换到「{title}"
# Multiple matches → selection mode (list up to 15)
self._enter_selection(key, "switch_session", rows)
items = "\n".join(
f" {i}. {title}" for i, (_, title) in enumerate(rows, 1)
)
return f"找到 {len(rows)} 个匹配(仅显示前{SESSION_LIST_LIMIT}个),请回复编号选择:\n{items}"
def _cmd_help(self, key: str, args: Optional[str]) -> str:
return self._cmd_guide
# ── Selection mode ──────────────────────────────────
def _enter_selection(self, key: str, action: str, options: list):
self._pending[key] = {
"action": action,
"options": options,
"expires": time.time() + SELECTION_TIMEOUT,
}
def _handle_selection(self, key: str, message: str) -> Optional[str]:
"""
Handle a user message while in selection mode.
Returns reply text if selection resolved, None if message should
be processed normally (selection expired or cancelled).
"""
pending = self._pending.get(key)
if not pending:
return None
if time.time() > pending["expires"]:
del self._pending[key]
return "选择已超时(120s),请重新操作。"
text = message.strip()
# Cancel
if text.lower() in ("cancel", "取消", "算了"):
del self._pending[key]
return "已取消。"
options = pending["options"]
# Number selection
if text.isdigit():
idx = int(text) - 1
if 0 <= idx < len(options):
sid, title = options[idx]
del self._pending[key]
if pending["action"] == "switch_session":
self._set_binding(key, sid)
self._reset_context(key)
return f"已切换到「{title}"
return "操作完成。"
return f"请输入 1-{len(options)} 之间的编号。"
# Keyword filter (narrow down within current options)
matches = [(s, t) for s, t in options if text in t]
if len(matches) == 1:
sid, title = matches[0]
del self._pending[key]
if pending["action"] == "switch_session":
self._set_binding(key, sid)
self._reset_context(key)
return f"已切换到「{title}"
return "操作完成。"
elif len(matches) > 1:
self._pending[key]["options"] = matches
items = "\n".join(
f" {i}. {t}" for i, (_, t) in enumerate(matches, 1)
)
return f"找到多个匹配,请再次选择:\n{items}"
# No match
items = "\n".join(
f" {i}. {t}" for i, (_, t) in enumerate(options, 1)
)
return f"未找到匹配。请回复编号或输入 cancel 取消:\n{items}"
# ── Context management for multi-turn command loops ──
def _reset_context(self, key: str):
"""Clear accumulated conversation context for a key."""
self._contexts.pop(key, None)
# ── Prompt building ─────────────────────────────────
def _build_prompt(self, key: str, history: list[dict]) -> str:
"""
Build prompt with session title + command layer.
注意:不注入 TUI session 上下文(extract_session_context),
因为群聊对话跟 TUI 对话是两套上下文。桥接后的群聊上下文
由 SessionBridge 自己的 context log 管理(更干净)。
"""
session_id = self._resolve_session(key)
session_title = self._get_session_title(session_id)
lines = [
f"[session: {session_title}]",
"",
]
# 不注入 TUI session 上下文,避免驴头不对马嘴
# ctx = extract_session_context(session_id, limit=20)
# if ctx:
# lines.append("【最近对话】")
# lines.append(ctx)
# lines.append("")
lines.append(
"[可用命令] 切换session用 ##switch_session:xxx## "
"列表用 ##list_sessions## ,帮助用 ##help## 。普通聊天无视。"
)
lines.append("---")
if history:
for entry in history:
role_label = {
"user": "用户",
"assistant": "小小莫",
"system": "系统",
}.get(entry["role"], entry["role"])
lines.append(f"{role_label}{entry['content']}")
return "\n".join(lines)
# ── Core LLM command loop ───────────────────────────
def _llm_loop(
self, key: str, history: list[dict], loop_count: int = 0
) -> str:
"""
Send to LLM → parse command → execute → loop.
Returns final reply text (all commands stripped).
"""
if loop_count >= MAX_LOOPS:
return "(命令循环次数超限,请重试)"
prompt = self._build_prompt(key, history)
reply = self.bridge.send_raw(prompt)
if not reply:
return "(模型无响应,请稍后重试)"
cmd, args, clean_text = self._parse_command(reply)
if not cmd:
# No command → this is the final answer
return clean_text or reply
# Execute command
handler = self._commands.get(cmd)
if not handler:
# Unknown command → treat as normal reply
return clean_text or reply
result = handler(key, args)
# Append to history (mutates the list, seen by recursive call) and loop
history.append(
{"role": "assistant", "content": clean_text or f"(执行{cmd}"}
)
history.append(
{
"role": "system",
"content": (
f"##{cmd}## 执行结果:{result}\n"
"(请根据结果继续回复用户,如有需要可在回复中继续使用命令)"
),
}
)
return self._llm_loop(key, history, loop_count + 1)
# ── Public entry point ──────────────────────────────
def route(self, channel: str, sender: str, message: str) -> str:
"""
Route a message from an external channel.
Args:
channel: "xmpp", "vc", or "wechat"
sender: user identifier (JID / UID / WXID)
message: raw text from the user
Returns:
reply text to send back (all ##commands## stripped)
"""
key = f"{channel}:{sender}"
# 1. Check selection mode
sel_reply = self._handle_selection(key, message)
if sel_reply is not None:
return sel_reply
# 2. Reset accumulated context for fresh conversation
self._reset_context(key)
# 3. Build initial prompt with user message + channel context
prefix = ""
if channel == "xmpp" and "/" in sender:
# XMPP group chat: sender is "room/nickname"
room = sender.split("/")[0]
nick = sender.split("/")[1]
# Include nick so LLM knows who said it
prefix = f"[群聊/{room.split('@')[0]}] {nick}: "
tagged = f"{prefix}{message}"
history = [{"role": "user", "content": tagged}]
# 4. Run LLM command loop
return self._llm_loop(key, history)
+39
View File
@@ -0,0 +1,39 @@
@echo off
title WeChat Agent
set PROJECT_DIR=D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway
set TOOLS_DIR=%PROJECT_DIR%\tools
set PYTHONW=C:\Users\hmo\AppData\Local\Programs\Python\Python310\pythonw.exe
set INJECTOR=%TOOLS_DIR%\Injector_x64.exe
set DLL=%TOOLS_DIR%\wxhelper_official_39581.dll
set LOG=%PROJECT_DIR%\logs\startup.log
echo [1/4] Waiting for WeChat...
:wait_wechat
tasklist /fi "imagename eq WeChat.exe" 2>nul | find /i "WeChat.exe" >nul
if errorlevel 1 (
timeout /t 2 /nobreak >nul
goto wait_wechat
)
echo [2/4] WeChat started, checking wxhelper...
curl -s -m 3 -X POST http://127.0.0.1:19088/api/checkLogin -H "Content-Type: application/json" -d "{}" 2>nul | find "code" >nul
if not errorlevel 1 (
echo [3/4] wxhelper OK, skipping inject
goto start_agent
)
echo [3/4] Injecting wxhelper...
%INJECTOR% -n WeChat.exe -i "%DLL%" >> "%LOG%" 2>&1
echo [3/4] Waiting for wxhelper HTTP...
:wait_wxhelper
timeout /t 2 /nobreak >nul
curl -s -m 3 -X POST http://127.0.0.1:19088/api/checkLogin -H "Content-Type: application/json" -d "{}" 2>nul | find "code" >nul
if errorlevel 1 goto wait_wxhelper
:start_agent
echo [4/4] Clearing cache and starting agent...
if exist "%PROJECT_DIR%\scripts\__pycache__" rmdir /s /q "%PROJECT_DIR%\scripts\__pycache__"
start "" "%PYTHONW%" "%PROJECT_DIR%\scripts\wechat_agent.py"
echo Done.
+28
View File
@@ -0,0 +1,28 @@
@echo off
title WeChat History API
cd /d "%~dp0.."
set PYTHON=python
echo ========================================
echo WeChat History REST API Server
echo Port: 19001
echo ========================================
echo.
echo Starting history API server...
echo Endpoints:
echo GET http://localhost:19001/
echo GET http://localhost:19001/health
echo GET http://localhost:19001/api/contacts
echo GET http://localhost:19001/api/recent
echo GET http://localhost:19001/api/history?wxid=wxid_xxx^&count=20
echo POST http://localhost:19001/api/history
echo ========================================
echo.
set PYTHONHOME=
%PYTHON% api\history_api.py --port 19001
if %errorlevel% neq 0 (
pause
)
+8
View File
@@ -0,0 +1,8 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0.."
set PYTHON=C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe
echo [api_proxy] 启动火山方舟代理...
start /B "" "%PYTHON%" scripts\api_proxy.py
echo [api_proxy] 已启动 (http://localhost:8787)
echo [api_proxy] 日志: logs\api_proxy.log
+262
View File
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgentsMeeting Dashboard</title>
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--border: #30363d;
--text: #c9d1d9;
--dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; padding:24px; min-height:100vh; }
h1 { font-size:20px; font-weight:600; color:var(--accent); margin-bottom:4px; }
.subtitle { color:var(--dim); font-size:12px; margin-bottom:20px; }
/* stats bar */
.stats { display:flex; gap:12px; margin-bottom:20px; flex-wrap:wrap; }
.stat-card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:12px 16px; flex:1; min-width:140px; }
.stat-card .num { font-size:28px; font-weight:700; }
.stat-card .label { font-size:11px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px; }
.stat-card.online .num { color:var(--green); }
.stat-card.offline .num { color:var(--red); }
.stat-card.errors .num { color:var(--yellow); }
.stat-card.total .num { color:var(--accent); }
/* agent cards */
.agents { display:flex; flex-direction:column; gap:12px; }
.agent-card { background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.agent-card.online { border-left:3px solid var(--green); }
.agent-card.degraded { border-left:3px solid var(--yellow); }
.agent-card.offline { border-left:3px solid var(--red); opacity:.7; }
.agent-header { display:flex; align-items:center; padding:14px 16px; gap:12px; cursor:pointer; }
.agent-header:hover { background:rgba(255,255,255,.02); }
.status-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
.status-dot.online { background:var(--green); box-shadow:0 0 6px var(--green); }
.status-dot.degraded { background:var(--yellow); box-shadow:0 0 6px var(--yellow); }
.status-dot.offline { background:var(--red); }
.agent-info { flex:1; min-width:0; }
.agent-name { font-size:15px; font-weight:600; }
.agent-meta { font-size:12px; color:var(--dim); margin-top:2px; }
.badge { display:inline-block; padding:1px 8px; border-radius:10px; font-size:10px; font-weight:600; margin-left:8px; }
.badge.windows { background:#0078d420; color:#60cdff; }
.badge.linux { background:#fcc62420; color:#fcc624; }
.badge.mac { background:#99999920; color:#ccc; }
.agent-stats { display:flex; gap:16px; align-items:center; padding-right:8px; flex-shrink:0; }
.agent-stat { text-align:right; }
.agent-stat .val { font-size:13px; font-weight:600; }
.agent-stat .lbl { font-size:10px; color:var(--dim); }
.agent-actions { display:flex; gap:4px; flex-shrink:0; }
.btn { padding:5px 12px; border:1px solid var(--border); border-radius:5px; background:#21262d; color:var(--text); cursor:pointer; font-size:11px; transition:all .15s; }
.btn:hover { background:#30363d; }
.btn.start { border-color:var(--green); color:var(--green); }
.btn.stop { border-color:var(--red); color:var(--red); }
.btn.restart { border-color:var(--yellow); color:var(--yellow); }
.btn.log { border-color:var(--accent); color:var(--accent); }
.btn:disabled { opacity:.4; cursor:not-allowed; }
/* services row */
.services { display:flex; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }
.service-tag { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:12px; font-size:11px; background:#0d1117; border:1px solid var(--border); }
.service-tag .dot { width:6px; height:6px; border-radius:50%; }
.service-tag .dot.running { background:var(--green); }
.service-tag .dot.stopped { background:var(--dim); }
.service-tag .port { color:var(--accent); font-family:monospace; font-size:10px; margin-left:4px; }
/* log panel */
.log-panel { display:none; border-top:1px solid var(--border); padding:12px 16px; background:#0a0e14; }
.log-panel.open { display:block; }
.log-header { font-size:12px; color:var(--dim); margin-bottom:8px; display:flex; justify-content:space-between; align-items:center; }
.log-content { font:11px/1.6 'Cascadia Code','Consolas',monospace; color:var(--dim); max-height:300px; overflow-y:auto; background:#06080c; border:1px solid var(--border); border-radius:6px; padding:10px; white-space:pre-wrap; word-break:break-all; }
.log-content::-webkit-scrollbar { width:6px; }
.log-content::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
/* toast */
.toast { position:fixed; top:16px; right:16px; padding:10px 16px; border-radius:6px; font-size:13px; z-index:999; opacity:0; transition:opacity .3s; pointer-events:none; }
.toast.show { opacity:1; }
.toast.ok { background:#238636; color:#fff; }
.toast.err { background:#da3633; color:#fff; }
/* loading */
.loading { text-align:center; padding:40px; color:var(--dim); }
.spinner { display:inline-block; width:20px; height:20px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin .6s linear infinite; margin-right:8px; vertical-align:middle; }
@keyframes spin { to { transform:rotate(360deg); } }
.expand { color:var(--dim); font-size:18px; margin-left:4px; }
.expand.open { color:var(--accent); }
/* platform section */
.platform-section { margin-top:24px; }
.platform-section h2 { font-size:16px; color:var(--dim); margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid var(--border); }
.platform-svcs { display:flex; gap:8px; flex-wrap:wrap; }
.platform-svc { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:12px; }
.platform-svc .dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
.platform-svc .dot.running { background:var(--green); }
.platform-svc .dot.stopped { background:var(--dim); }
.platform-svc .type { color:var(--accent); font-size:10px; text-transform:uppercase; }
</style>
</head>
<body>
<h1>AgentsMeeting Dashboard</h1>
<div class="subtitle" id="subtitle">Loading...</div>
<div class="stats" id="stats"></div>
<div class="agents" id="agents"><div class="loading"><span class="spinner"></span>Loading agents...</div></div>
<div class="platform-section">
<h2>Infrastructure</h2>
<div class="platform-svcs" id="platform"></div>
<div class="platform-svcs" id="ejabberd-status" style="margin-top:8px;"></div>
</div>
<div id="toast" class="toast"></div>
<script>
const API = '/api/agents';
let agentsData = [];
const openLogs = new Set();
function toast(msg, type) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show ' + type;
setTimeout(() => t.className = 'toast', 2500);
}
async function doAction(id, action) {
try {
const r = await fetch(API + '/' + id + '/' + action, { method:'POST' });
const d = await r.json();
toast(action + (d.ok ? ' OK' : ' FAILED'), d.ok ? 'ok' : 'err');
setTimeout(fetchAgents, 1500);
} catch(e) { toast('Error: ' + e.message, 'err'); }
}
function toggleLogs(id) {
if (openLogs.has(id)) openLogs.delete(id);
else openLogs.add(id);
render();
if (openLogs.has(id)) fetchLogs(id);
}
async function fetchLogs(id) {
const el = document.getElementById('log-' + id);
if (!el || !openLogs.has(id)) return;
el.style.opacity = '0.5';
try {
const r = await fetch(API + '/' + id + '/logs?lines=50');
const d = await r.json();
el.innerHTML = (d.lines || []).map(l => '<div>' + esc(l) + '</div>').join('') || '<span style="color:var(--dim)">no log data</span>';
} catch(e) {
el.innerHTML = '<span style="color:var(--red)">Failed: ' + esc(e.message) + '</span>';
}
el.style.opacity = '1';
el.scrollTop = el.scrollHeight;
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'<').replace(/>/g,'>'); }
function render() {
if (!agentsData.length) {
document.getElementById('agents').innerHTML = '<div class="loading">No agents registered</div>';
return;
}
document.getElementById('agents').innerHTML = agentsData.map(a => `
<div class="agent-card ${a.status}">
<div class="agent-header" onclick="toggleLogs('${a.id}')">
<span class="status-dot ${a.status}"></span>
<div class="agent-info">
<span class="agent-name">${esc(a.display_name)}</span>
<span class="badge ${a.platform}">${a.platform}</span>
<div class="agent-meta">${esc(a.jid)} &middot; ${esc(a.host)} ${a.pid ? '&middot; PID '+a.pid : ''}</div>
</div>
<div class="agent-stats">
${a.last_message ? '<div class="agent-stat"><div class="val">'+esc(a.last_message)+'</div><div class="lbl">last msg</div></div>' : ''}
<div class="agent-stat"><div class="val">${a.message_count_5min}</div><div class="lbl">msgs/5min</div></div>
${a.errors ? '<div class="agent-stat"><div class="val" style="color:var(--red)">'+a.errors+'</div><div class="lbl">errors</div></div>' : ''}
</div>
<div class="agent-actions" onclick="event.stopPropagation()">
<button class="btn start" onclick="doAction('${a.id}','start')" ${(a.status!=='offline'||!a.restartable)?'disabled':''}>Start</button>
<button class="btn stop" onclick="doAction('${a.id}','stop')" ${(a.status==='offline'||!a.restartable)?'disabled':''}>Stop</button>
<button class="btn restart" onclick="doAction('${a.id}','restart')" ${!a.restartable?'disabled':''}>Restart</button>
</div>
<span class="expand">${openLogs.has(a.id) ? '&#9650;' : '&#9660;'}</span>
</div>
<div class="services">
${(a.services || []).map(s => '<span class="service-tag"><span class="dot '+s.status+'"></span>'+esc(s.type)+(s.port?' <span class="port">:'+s.port+'</span>':'')+(s.pid?' PID:'+s.pid:'')+'</span>').join('')}
</div>
<div class="log-panel ${openLogs.has(a.id) ? 'open' : ''}" id="log-${a.id}">
<div class="log-header">
<span>Agent Logs</span>
<button class="btn log" onclick="fetchLogs('${a.id}');event.stopPropagation()">Refresh</button>
</div>
<div class="log-content">Click arrow to load logs</div>
</div>
</div>
`).join('');
}
function renderStats() {
const online = agentsData.filter(a => a.status === 'online').length;
const offline = agentsData.filter(a => a.status === 'offline').length;
const degraded = agentsData.filter(a => a.status === 'degraded').length;
const errors = agentsData.reduce((s, a) => s + (a.errors || 0), 0);
document.getElementById('stats').innerHTML = `
<div class="stat-card online"><div class="num">${online}</div><div class="label">Online</div></div>
<div class="stat-card"><div class="num" style="color:${degraded?'var(--yellow)':'var(--dim)'}">${degraded}</div><div class="label">Degraded</div></div>
<div class="stat-card offline"><div class="num">${offline}</div><div class="label">Offline</div></div>
<div class="stat-card errors"><div class="num">${errors}</div><div class="label">Errors</div></div>
<div class="stat-card total"><div class="num">${agentsData.length}</div><div class="label">Total</div></div>
`;
}
async function fetchAgents() {
try {
const r = await fetch(API);
agentsData = await r.json();
renderStats();
render();
document.getElementById('subtitle').textContent = 'Last update: ' + new Date().toLocaleTimeString();
openLogs.forEach(id => fetchLogs(id));
} catch(e) {
document.getElementById('agents').innerHTML = '<div class="loading" style="color:var(--red)"><span class="spinner" style="border-top-color:var(--red)"></span>Cannot connect to backend (:5803). Is dashboard.py running?</div>';
}
}
async function fetchPlatform() {
try {
const r = await fetch('/api/platform');
const data = await r.json();
document.getElementById('platform').innerHTML = data.map(s => '<div class="platform-svc"><span class="dot '+s.status+'"></span><strong>'+esc(s.name)+'</strong><span class="type">'+s.type+'</span><span style="margin-left:auto;color:var(--dim)">'+(s.pid?'PID:'+s.pid:'')+'</span></div>').join('');
} catch(e) {}
}
async function fetchEjabberd() {
try {
const r = await fetch('/api/ejabberd');
const d = await r.json();
const alive = d.alive || d.xmpp_bot_connected;
document.getElementById('ejabberd-status').innerHTML = '<div class="platform-svc"><span class="dot '+(alive?'running':'stopped')+'"></span><strong>Ejabberd XMPP Server</strong><span class="type">'+(alive?'ALIVE':'DOWN')+'</span><span style="margin-left:auto;color:var(--dim)">online: '+(d.online_jids||[]).join(', ')+'</span></div>';
} catch(e) {}
}
fetchAgents();
fetchPlatform();
fetchEjabberd();
setInterval(fetchAgents, 5000);
setInterval(fetchPlatform, 10000);
setInterval(fetchEjabberd, 10000);
setInterval(() => openLogs.forEach(id => fetchLogs(id)), 3000);
</script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
"""
VoceChat Webhook → Session Bridge.
Receives VoceChat webhook events, forwards to opencode serve session,
captures AI reply, and sends it back to the VC group.
"""
import os, sys, json, time, threading, urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from chat_bridge import SessionBridge
from session_router import SessionRouter
from proc_guard import guard as _proc_guard
# ── PID lock — prevent duplicate instances ──
_lock = _proc_guard("vc_webhook")
if not _lock.ok:
print(_lock.message, flush=True)
sys.exit(1)
# ── Config ────────────────────────────────────────────────
SERVE_URL = "http://127.0.0.1:4096"
ATTACH_SESSION = "ses_1d95d15c4ffehQaZ6hrbIbak5k"
VC_API = "http://192.168.1.246:3009"
VC_BOT_KEY = os.environ.get(
"VC_BOT_KEY",
"5b2bd4ce2e0395503b4849a69a47a4e2a3f7aa81af242d2666b31e7519589c477b22756964223a362c226e6f6e6365223a2252576a744643384947476f41414141417a4c6a6e355a7a484731723839494b59227d")
VC_SELF_UID = 6
SPEAKERS = {1: "老爸", 5: "莫荷", VC_SELF_UID: "莫笑笑"}
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
LOG_FILE = os.path.join(LOG_DIR, "vc_webhook.log")
os.makedirs(LOG_DIR, exist_ok=True)
# ── Logging ───────────────────────────────────────────────
def log(msg: str):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
# ── Router ────────────────────────────────────────────────
_router = SessionRouter(
bridge=SessionBridge(session_id=ATTACH_SESSION, serve_url=SERVE_URL),
default_session=ATTACH_SESSION,
)
def _speaker(uid: int) -> str:
return SPEAKERS.get(uid, f"用户{uid}")
def _send_to_vc_group(gid: int, text: str):
"""Post reply to a VoceChat group via Bot API."""
url = f"{VC_API}/api/bot/send_to_group/{gid}"
headers = {"X-API-Key": VC_BOT_KEY, "Content-Type": "text/plain"}
urllib.request.urlopen(
urllib.request.Request(url, data=text.encode("utf-8"), headers=headers),
timeout=10)
def _process_message(content: str, sender: int, data: dict):
"""VC message → router → reply → VC."""
if sender == VC_SELF_UID:
return
log(f"router.route: sender={sender} content={content[:50]}...")
reply = _router.route("vc", str(sender), content)
if reply:
log(f"reply[:80]={reply[:80]}")
gid = data.get("target", {}).get("gid", 0)
if gid:
try:
_send_to_vc_group(gid, reply)
log(f"Replied to VC group {gid}")
except Exception as e:
log(f"VC reply ERR: {e}")
else:
log("no text reply in time")
# ── HTTP Handler ──────────────────────────────────────────
class WebhookHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
log(f"RAW: {body.decode('utf-8', errors='replace')[:300]}")
try:
data = json.loads(body)
if data.get("type") in ("new_message", "chat"):
detail = data.get("detail", {})
content = detail.get("content", "") or data.get("content", "")
sender = data.get("from_uid", 0)
log(f"MSG uid={sender}: {str(content)[:80]}")
threading.Thread(
target=_process_message,
args=(str(content), sender, data),
daemon=True,
).start()
except Exception as e:
log(f"ERR: {e}")
self.send_response(200)
self.end_headers()
def log_message(self, *args):
pass
# ── Main ──────────────────────────────────────────────────
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8010), WebhookHandler)
log("VC webhook listening on :8010")
server.serve_forever()
+982
View File
@@ -0,0 +1,982 @@
"""
WeChat Agent v2 - wxhelper DLL + Hermes API (:8642)
"""
import os, json, time, threading, requests, re, socketserver, subprocess, urllib.request, urllib.error, queue, locale
import warnings
warnings.filterwarnings("ignore", message=".*urllib3.*")
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from chat_bridge import SessionBridge
from session_router import SessionRouter
from proc_guard import guard as _proc_guard
# ── PID lock — prevent duplicate instances ──
_lock = _proc_guard("wechat_agent")
if not _lock.ok:
print(_lock.message, flush=True)
sys.exit(1)
BOT_WXID = "wxid_5bhmquvkbude22"
BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # ϵͳ?˺?/΢???Ŷӣ----ظ?
WX_API = "http://127.0.0.1:19088"
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
LOG_FILE = os.path.join(LOG_DIR, "wechat_agent.log")
os.makedirs(LOG_DIR, exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)
TCP_PORT = 19099
last_msg_time = time.time()
nickname_cache = {}
db_handle_cache = None # MicroMsg.db handle for history queries
# ── 莫荷↔小小莫 对话记忆 (本地持久化, 可检索) ──
MEMORY_DIR = os.path.join(PROJECT_ROOT, "mohe_memory")
MEMORY_FILE = os.path.join(MEMORY_DIR, "conversations.jsonl")
os.makedirs(MEMORY_DIR, exist_ok=True)
# 当前serve session ID (莫荷消息进这个session, LLM自动有上下文)
ATTACH_SESSION = "ses_1d95d15c4ffehQaZ6hrbIbak5k"
SESSION_CTX_FILE = os.path.join(MEMORY_DIR, "session_context.txt")
_ctx_last_refresh = 0
_memory_counter = 0
def _next_memory_id():
global _memory_counter
_memory_counter += 1
return _memory_counter
def append_mohe_memory(direction, content):
"""Append one exchange to the append-only log."""
entry = {"id": _next_memory_id(), "ts": int(time.time()),
"direction": direction, "content": content}
try:
with open(MEMORY_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception as e:
log(f"memory append ERR: {e}")
def read_mohe_context(n=30):
"""Read last n exchanges, return as formatted context string."""
try:
if not os.path.exists(MEMORY_FILE):
return ""
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
lines = f.readlines()
recent = lines[-n:] if len(lines) > n else lines
parts = []
for line in recent:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
who = "莫荷" if entry.get("direction") == "mohe_to_xxm" else "小小莫"
parts.append(f"{who}: {entry.get('content', '')[:200]}")
except json.JSONDecodeError:
continue
return "\n".join(parts)
except Exception as e:
log(f"memory read ERR: {e}")
return ""
def search_mohe_memory(keyword, max_results=10):
"""Search conversation memory by keyword. Returns list of matching entries."""
results = []
try:
if not os.path.exists(MEMORY_FILE):
return results
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or keyword not in line:
continue
try:
entry = json.loads(line)
results.append(entry)
if len(results) >= max_results:
break
except json.JSONDecodeError:
continue
except Exception as e:
log(f"memory search ERR: {e}")
return results
# Session bridge + router — shared with vc_webhook / xmpp
attach_bridge = SessionBridge(session_id=ATTACH_SESSION, serve_url="http://127.0.0.1:4096")
_router = SessionRouter(
bridge=attach_bridge,
default_session=ATTACH_SESSION,
)
attach_queue = queue.Queue()
_attach_worker_started = False
def _attach_worker():
"""Single worker: processes attach_queue one at a time."""
while True:
try:
msg_text = attach_queue.get()
if msg_text is None:
break
qsize = attach_queue.qsize()
if qsize > 0:
log(f"attach_queue: {qsize} pending after this")
do_attach(msg_text)
except Exception as e:
log(f"_attach_worker ERR: {e}")
def queue_attach(msg_text):
"""Enqueue a message for serialized async processing (one at a time)."""
global _attach_worker_started
if not _attach_worker_started:
_attach_worker_started = True
threading.Thread(target=_attach_worker, daemon=True).start()
attach_queue.put(msg_text)
log(f"queue_attach: queued ({attach_queue.qsize()} pending)")
def clear_attach_queue():
"""Clear all pending messages in the attach queue (stop mechanism)."""
n = 0
while not attach_queue.empty():
try:
attach_queue.get_nowait()
n += 1
except queue.Empty:
break
log(f"clear_attach_queue: cleared {n} pending messages")
return n
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
HERMES_KEY = "hermes123"
SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO"
SENSENOVA_URL = "https://token.sensenova.cn/v1"
INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\Injector_x64.exe"
WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_official_39581.dll"
def log(m):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
def wxpost(path, data=None, timeout=10):
try:
body = json.dumps(data or {}, ensure_ascii=False).encode("utf-8")
r = urllib.request.urlopen(urllib.request.Request(WX_API + path, data=body, headers={"Content-Type": "application/json; charset=utf-8"}), timeout=timeout)
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode()) if e.code else {"code": -1}
except Exception as e:
log(f"WX ERR: {e}")
return {"code": -1}
# ---- History Query (via MSG table in MSG*.db databases) ----
def get_db_handle():
"""Get handle for database containing MSG table. Cached after first call."""
global db_handle_cache
if db_handle_cache:
return db_handle_cache
r = wxpost("/api/getDBInfo", timeout=10)
dbs = r.get("data") or []
# WeChat 3.9.5.81+: messages stored in MSG0.db, MSG1.db, etc.
# Also check ChatMsg.db (has ChatMsg table with different schema).
# Prefer MSG*.db over MicroMsg.db (MicroMsg.db has "Msg" in name but no MSG table in new versions).
candidate = None
for db in dbs:
dbname = db.get("databaseName", "")
# Prefer MSG0.db/MSG1.db over MicroMsg.db
if dbname.upper().startswith("MSG") and dbname.upper().endswith(".DB"):
candidate = db.get("handle")
log(f"History DB: {dbname} handle={candidate}")
break
# Fallback: check if any table is named MSG
for t in (db.get("tables") or []):
if t.get("tableName") == "MSG":
candidate = db.get("handle")
log(f"History DB: {dbname} handle={candidate}")
break
if candidate:
break
if candidate:
db_handle_cache = candidate
return candidate
log("History DB handle: NOT FOUND")
return None
# Message type labels
MSG_TYPES = {1: "----", 3: "ͼƬ", 34: "----", 43: "??Ƶ", 47: "----", 49: "----", 10000: "ϵͳ", 10002: "???"}
def query_history(wxid, limit=10):
"""Query historical text messages with a contact from MSG table."""
h = get_db_handle()
if not h:
return None
# Text (type=1) and appmsg/link (type=49), use DisplayContent as fallback for StrContent
limit_val = min(int(limit), 50)
sql = f"SELECT CreateTime, IsSender, Type, SubType, StrContent, DisplayContent FROM MSG WHERE StrTalker='{wxid}' AND Type IN (1,49) ORDER BY CreateTime DESC LIMIT {limit_val}"
r = wxpost("/api/execSql", {"dbHandle": h, "sql": sql}, timeout=15)
data = r.get("data") or []
if not data or len(data) < 2:
return None
# Skip header row, reverse to chronological order
rows = data[1:]
rows.reverse()
# Normalize content: prefer StrContent, fallback to DisplayContent
results = []
for row in rows:
content = (row[4] or "").strip() if len(row) > 4 else ""
if not content and len(row) > 5:
content = (row[5] or "").strip()
if not content:
continue
results.append({"CreateTime": row[0], "IsSender": row[1], "Type": row[2], "content": content})
return results
def format_history(wxid, rows):
"""Format MSG rows into readable chat history text."""
sender_name = get_nickname(wxid)
bot_name = get_nickname(BOT_WXID)
lines = [f"?? ----? {sender_name} ----???¼ ({len(rows)}??):"]
for row in rows:
ts = int(row.get("CreateTime", 0))
time_str = time.strftime("%m/%d %H:%M", time.localtime(ts)) if ts else "?"
is_sender = int(row.get("IsSender", 0))
msg_type = int(row.get("Type", 1))
content = row.get("content", "")
# Determine who sent it
who = bot_name if is_sender else sender_name
# Format content
if msg_type == 49:
content = f"[----] {content[:60]}"
else:
content = content[:200]
lines.append(f"[{time_str}] {who}: {content}")
return "\n".join(lines)
def handle_history(wxid, count):
"""Query and format history for a given wxid. Returns text to send."""
try:
rows = query_history(wxid, count)
if rows:
return format_history(wxid, rows)
return f"----?? {get_nickname(wxid)} ----???¼"
except Exception as e:
log(f"History ERR: {e}")
return "??ѯ??ʷ??¼ʧ??"
def handle_history_json(wxid, count):
"""Query history and return JSON-serializable dict for HTTP API."""
try:
rows = query_history(wxid, count)
sender_name = get_nickname(wxid)
if not rows:
return {"ok": True, "wxid": wxid, "sender_name": sender_name, "count": 0, "messages": []}
bot_name = get_nickname(BOT_WXID)
messages = []
for row in rows:
ts = int(row.get("CreateTime", 0))
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else ""
is_sender = int(row.get("IsSender", 0))
msg_type = int(row.get("Type", 1))
content = row.get("content", "")
messages.append({
"time": time_str,
"timestamp": ts,
"sender": bot_name if is_sender else sender_name,
"is_self": bool(is_sender),
"type": msg_type,
"content": content[:500]
})
return {
"ok": True,
"wxid": wxid,
"sender_name": sender_name,
"count": len(messages),
"requested_count": min(int(count or 10), 50),
"messages": messages
}
except Exception as e:
log(f"History JSON ERR: {e}")
return {"ok": False, "error": str(e)[:200]}
def send_wx(wxid, msg):
# Strip weixin:// URLs that WeChat interprets as commands
import re as _re2
msg = _re2.sub(r'weixin://[^\s]+', '[----?ѹ???]', msg)
r = wxpost("/api/sendTextMsg", {"wxid": wxid, "msg": msg})
log(f"SEND {wxid}: {r.get('msg','')}")
def get_nickname(wxid):
if wxid in nickname_cache:
return nickname_cache[wxid]
r = wxpost("/api/getContactList", timeout=10)
for c in (r.get("data") or []):
if c.get("wxid") == wxid:
nick = c.get("nickname") or c.get("customAccount") or wxid
nickname_cache[wxid] = nick
return nick
nickname_cache[wxid] = wxid
return wxid
def call_hermes(wxid, content):
nickname = get_nickname(wxid)
headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"}
# 群聊 vs 私聊自动适配:群聊有接龙游戏时直接参与,不分析规则
is_group = "@chatroom" in wxid
if is_group:
sys_prompt = "你是莫荷,女生。群聊中回复要短。有人玩成语接龙时,看到「接X字」直接以X开头接一个成语继续,不要分析规则、不要解释、不要评价。保持接龙节奏不打断。"
else:
sys_prompt = "你是莫荷,女生。回复简短自然,像朋友聊天。"
body = {"model": "hermes-agent", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]}
log(f"CALL_HERMES content[:120]={content[:120]}")
log(f"CALL_HERMES body user_msg={json.dumps(body, ensure_ascii=False)[:200]}")
try:
r = requests.post(HERMES_API, json=body, headers=headers, proxies={"http": None, "https": None})
if r.status_code == 200:
data = r.json()
choice = data["choices"][0]
# Observer pattern: Gateway returns finish_reason="silent" for group messages that don't need reply
if choice.get("finish_reason") == "silent":
log("Hermes: __SILENT__ (group, skip)")
return None
return choice["message"]["content"]
except Exception as e:
log(f"API ERR: {e}")
return None
def inject_to_hermes_session(text):
"""Inject chat history / context directly into Hermes's sisyphus session for memory repair."""
headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"}
sys_prompt = "?? MEMORY INJECTION: Below is past chat history. Absorb this into your context for memory repair. Do NOT reply to this ?? just acknowledge with 'Memory synced.'"
body = {"model": "hermes-agent", "messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": text}
]}
try:
r = requests.post(HERMES_API, json=body, headers=headers, proxies={"http": None, "https": None})
if r.status_code == 200:
reply = r.json()["choices"][0]["message"]["content"]
log(f"Inject history OK: {reply[:50]}")
return True
log(f"Inject history HTTP {r.status_code}")
except Exception as e:
log(f"Inject history ERR: {e}")
return False
# ---- Inject wxhelper DLL ----
def inject_wxhelper(force=False):
if not force:
try:
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
log("wxhelper already injected")
return True
except:
pass
# Also check if port 19088 is just listening (wxhelper HTTP server alive)
try:
import socket as _sock
s = _sock.create_connection(("127.0.0.1", 19088), timeout=2)
s.close()
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
log("wxhelper HTTP server alive, login OK")
return True
except:
pass
# Wait a moment in case server is still starting
time.sleep(3)
try:
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
log("wxhelper responding after wait")
return True
except:
pass
try:
# Injector_x64.exe: -n process_name -i dll_path
result = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30)
output = (result.stdout + result.stderr).strip()
log(f"Inject: {output[:100]}")
# Check if injection succeeded by looking for "success" in output
if "success" not in output.lower():
log(f"Inject MAY HAVE FAILED (no 'success' in output), retrying...")
time.sleep(2)
result2 = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30)
log(f"Inject retry: {(result2.stdout+result2.stderr).strip()[:100]}")
time.sleep(3)
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
log("wxhelper injected OK")
return True
log(f"Inject check: {r}")
return False
except Exception as e:
log(f"Inject FAIL: {e}")
return False
# ---- TCP Message Receiver ----
class MsgHandler(socketserver.BaseRequestHandler):
def handle(self):
try:
data = b""
while True:
c = self.request.recv(4096)
data += c
if not c or c[-1] == 10:
break
if data.strip():
threading.Thread(target=process_msg, args=(data,), daemon=True).start()
self.request.sendall(b"200 OK\n")
except:
pass
finally:
self.request.close()
# ---- Image OCR ----
WX_FILES_BASE = os.path.join(os.path.expanduser("~"), "Documents", "WeChat Files")
BOT_WX_DIR = os.path.join(WX_FILES_BASE, BOT_WXID, "wxhelper")
def ocr_image(base64_data):
"""OCR from in-memory base64 image data. Returns text or None."""
try:
headers = {"Authorization": "Bearer b0359bed-09f2-49e2-a53c-32ba057412e3", "Content-Type": "application/json"}
payload = {
"model": "doubao-seed-code",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "请识别这张图片中的所有中文和英文字符,保持原文输出,包括数字、表格、百分比的完整结构。严格逐行逐列输出所有数据,不要省略、不要总结。"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_data}"}}
]
}]
}
r = requests.post(
"https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
json=payload, headers=headers, timeout=60,
proxies={"http": None, "https": None}
)
if r.status_code == 200:
text = r.json()["choices"][0]["message"]["content"].strip()
log(f"OCR OK ({len(text)} chars)")
return text
log(f"OCR HTTP {r.status_code}: {r.text[:200]}")
except Exception as e:
log(f"OCR ERR: {e}")
return None
def ocr_image_file(image_path):
"""OCR an image file on disk. Returns text or None."""
try:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return ocr_image(b64)
except Exception as e:
log(f"ocr_image_file ERR: {e}")
return None
# ---- Full Image Download & Decode (wxhelper 3.9.5.81+) ----
def download_full_image(msg_id):
"""Download full image from CDN via downloadAttach. Returns encrypted .dat path or None.
Retries both the API call (wxhelper may return -2 transiently)
and file existence (async CDN download takes time).
"""
try:
dat_path = os.path.join(BOT_WX_DIR, "image", f"{msg_id}.dat")
# Phase 1: Retry API call (wxhelper may return -2 if msg not ready)
for api_attempt in range(10):
r = wxpost("/api/downloadAttach", {"msgId": int(msg_id)}, timeout=30)
code = r.get("code", -1)
if code >= 0:
break
log(f"downloadAttach attempt {api_attempt+1}: code={code} {r.get('msg','')}")
time.sleep(1)
else:
log(f"downloadAttach FAILED after 10 attempts, last code={code}")
return None
# Phase 2: Wait for async CDN download
log(f"downloadAttach queued, waiting for file...")
for wait_attempt in range(20):
if os.path.exists(dat_path):
log(f"Download OK: {dat_path} ({os.path.getsize(dat_path)} bytes)")
return dat_path
time.sleep(1)
log(f"downloadAttach: .dat not found after 20s for msgId={msg_id}")
except Exception as e:
log(f"downloadAttach ERR: {e}")
return None
def decode_image_file(dat_path):
"""Decrypt encrypted .dat to viewable image. Returns decoded path or None.
Some .dat files are already valid PNG/JPEG images (not encrypted).
Falls back to checking if .dat itself is a valid image.
"""
try:
before_files = set(os.listdir(TEMP_DIR))
r = wxpost("/api/decodeImage", {"filePath": dat_path, "storeDir": TEMP_DIR}, timeout=30)
if r.get("code", -1) > 0:
base = os.path.splitext(os.path.basename(dat_path))[0]
for ext in ['.jpg', '.jpeg', '.png', '.bmp']:
cand = os.path.join(TEMP_DIR, base + ext)
if os.path.exists(cand):
log(f"Decoded: {cand}")
return cand
for f in os.listdir(TEMP_DIR):
if f in before_files: continue
if f.lower().endswith(('.jpg', '.jpeg', '.png')):
cand = os.path.join(TEMP_DIR, f)
log(f"Decoded (new): {cand}")
return cand
log("decodeImage OK but no new image file found")
# Fallback: .dat file may already be a valid image (not encrypted)
with open(dat_path, "rb") as f:
header = f.read(4)
ext = None
if header[:2] == b'\xff\xd8': # JPEG
ext = '.jpg'
elif header[:4] == b'\x89PNG': # PNG
ext = '.png'
elif header[:4] == b'GIF8': # GIF
ext = '.gif'
elif header[:2] == b'BM': # BMP
ext = '.bmp'
if ext:
out_path = os.path.join(TEMP_DIR, os.path.splitext(os.path.basename(dat_path))[0] + ext)
import shutil
shutil.copy(dat_path, out_path)
log(f".dat is already {ext}, copied to {out_path}")
return out_path
log(f"decodeImage FAIL: code={r.get('code')} {r.get('msg','')}")
except Exception as e:
log(f"decodeImage ERR: {e}")
return None
def process_msg(raw_data):
global last_msg_time, last_raw_msg_time
last_msg_time = time.time()
last_raw_msg_time = time.time()
try:
d = json.loads(raw_data)
log(f"RAW: fromUser={d.get('fromUser','')} type={d.get('type','')} self={d.get('isSelf',d.get('self',0))}")
fu = d.get("fromUser", "") or d.get("fromuser", "") or d.get("sender", "")
ct = d.get("content", "") or d.get("msg", "") or d.get("text", "")
msg_type = d.get("type", 1)
is_self = d.get("isSelf", 0) or d.get("self", 0)
if "@chatroom" in fu:
log(f"GROUP RAW DUMP: keys={list(d.keys())} ct_len={len(ct)} ct[:100]={ct[:100]}")
if not fu or not ct or fu == BOT_WXID or fu in BLOCK_WXIDS or fu.startswith("gh_") or is_self:
log(f"SKIP: fu={fu} self={is_self}")
return
# Route by message type
if msg_type == 34: # Voice
log(f"<- {fu}: [voice]")
reply = call_hermes(fu, "[voice message]")
if reply and reply.strip():
send_wx(fu, reply.strip())
return
if msg_type == 3: # Image
msg_id = d.get("msgId", 0) or d.get("svrid", 0)
log(f"IMAGE: msgId={msg_id} b64_len={len(d.get('base64Img',''))}")
ocr_text = None
# Full-image OCR via wxhelper 3.9.5.81 APIs
if msg_id:
dat_path = download_full_image(msg_id)
if dat_path:
decoded = decode_image_file(dat_path)
if decoded:
log(f"Full image OCR on {decoded}")
ocr_text = ocr_image_file(decoded)
if ocr_text:
log(f"OCR result ({len(ocr_text)} chars): {ocr_text[:200]}")
reply = call_hermes(fu, f"[老莫发送了一张图片,OCR识别结果如下]\n{ocr_text}")
elif msg_id:
log("Full-image OCR failed, skipping thumbnail (useless at 84x210)")
reply = call_hermes(fu, "[老莫发送了一张图片,但全尺寸图片下载或OCR识别失败,无法读取内容]")
else:
log("No msgId available, cannot download full image")
reply = call_hermes(fu, "[老莫发送了一张图片,但无法获取图片ID,无法识别]")
if reply and reply.strip():
log(f"-> {fu}: {reply[:50]}")
process_tags(reply, fu)
else:
log(f"-> {fu}: skip (blank image response)")
return
# Text - prepend sender wxid+name so Hermes knows who's talking
sender_name = get_nickname(fu)
chat_type = "Group" if "@chatroom" in fu else "Private"
msg_with_sender = f"[{chat_type}][{fu}|{sender_name}] {ct}"
log(f"<- {fu} ({sender_name}): {ct[:50]}")
log(f"TO HERMES: [{chat_type}] {ct[:80]}")
log(f"TO HERMES FULL: {msg_with_sender[:150]}")
reply = call_hermes(fu, msg_with_sender)
if reply and reply.strip():
log(f"-> {fu}: {reply[:50]}")
process_tags(reply, fu)
else:
log(f"-> {fu}: no reply (blank/empty)")
except Exception as e:
log(f"MSG ERR: {e}")
import traceback
log(f"TRACE: {traceback.format_exc()[:200]}")
def process_tags(reply, fu):
if not reply:
return
clean = reply
# [FILE]
for tag, pattern, repl in [
("FILE", r'\[FILE\](.*?)\[/FILE\]', lambda m: download_and_send_file(m, fu)),
("IMG", r'\[IMG\](.*?)\[/IMG\]', lambda m: handle_img(m, fu)),
("EMOJI", r'\[EMOJI\](.*?)\[/EMOJI\]', lambda m: download_emoji(m, fu)),
]:
match = re.search(pattern, clean)
if match:
clean = re.sub(r'\s*' + pattern.replace('(.*?)', '.*?') + r'\s*', '', clean).strip()
try:
match = re.search(pattern, reply) # re-match against original
if match:
threading.Thread(target=repl, args=(match,), daemon=True).start()
except Exception as e:
log(f"[{tag}] Thread start ERR: {e}")
# [CONTACT:wxid]
cm = re.search(r'\[CONTACT:(\w+)\]', clean)
if cm:
clean = re.sub(r'\s*\[CONTACT:\w+\]\s*', '', clean).strip()
r = wxpost("/api/getContactProfile", {"wxid": cm.group(1)})
cd = r.get("data", {})
send_wx(fu, f"?dz?: {cd.get('nickname','?')} ??ע: {cd.get('remark','')}")
# [ROOM_MEMBERS:roomid]
rm = re.search(r'\[ROOM_MEMBERS:(\S+)\]', clean)
if rm:
clean = re.sub(r'\s*\[ROOM_MEMBERS:\S+\]\s*', '', clean).strip()
r = wxpost("/api/getMemberFromChatRoom", {"chatRoomId": rm.group(1)})
members = (r.get("data") or {}).get("members", "")
mlist = [m for m in members.split("\u0007") if m]
send_wx(fu, f"Ⱥ??Ա ({len(mlist)}): {','.join(mlist[:20])}")
# [HISTORY:wxid:count] - query chat history from MSG table
hm = re.search(r'\[HISTORY:(\S+?):(\d+)\]', clean)
if hm:
clean = re.sub(r'\s*\[HISTORY:\S+?:\d+\]\s*', '', clean).strip()
target_wxid, count = hm.group(1), int(hm.group(2))
threading.Thread(target=lambda: send_wx(fu, handle_history(target_wxid, count)), daemon=True).start()
# [PAT:roomid:wxid]
pm = re.search(r'\[PAT:(\S+):(\S+)\]', clean)
if pm:
clean = re.sub(r'\s*\[PAT:\S+:\S+\]\s*', '', clean).strip()
wxpost("/api/sendPatMsg", {"receiver": pm.group(1), "wxid": pm.group(2)})
if clean.strip():
send_wx(fu, clean.strip())
def download_and_send_file(m, fu):
url = m.group(1).strip()
log(f"[FILE] Downloading: {url}")
try:
ir = requests.get(url, timeout=60, proxies={"http": None, "https": None})
log(f"[FILE] HTTP {ir.status_code}, size={len(ir.content)}")
if ir.status_code == 200:
# Preserve original file extension so wxhelper can detect file type
ext = os.path.splitext(urlparse(url).path)[-1] or ".dat"
if ext.lower() not in ('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.txt', '.zip', '.rar', '.jpg', '.png', '.gif', '.mp3', '.mp4'):
ext = ".dat"
tmp = os.path.join(TEMP_DIR, f"send_file_{int(time.time())}{ext}")
with open(tmp, "wb") as f:
f.write(ir.content)
log(f"[FILE] Saved to {tmp}, sending via wxhelper...")
r = wxpost("/api/sendFileMsg", {"wxid": fu, "filePath": tmp})
log(f"[FILE] wxpost result: {r.get('code','?')} {r.get('msg','?')}")
# Keep file alive briefly for async wxhelper read
time.sleep(1)
try:
os.remove(tmp)
except:
pass
else:
log(f"[FILE] Download FAILED: HTTP {ir.status_code}")
except Exception as e:
log(f"[FILE] ERR: {e}")
def handle_img(m, fu):
cmd = m.group(1).strip()
if cmd.startswith("generate:") or cmd.startswith("draw:"):
parts = cmd.split(":", 1)[1].strip()
ratio = "1:1"
if "|" in parts:
ratio = parts.split("|")[1].strip()
prompt = parts.split("|")[0].strip()
else:
prompt = parts
size_map = {"1:1":"2048x2048","16:9":"2752x1536","9:16":"1536x2752","3:2":"2496x1664","2:3":"1664x2496","3:4":"1760x2368","4:3":"2368x1760"}
size = size_map.get(ratio, "2048x2048")
log(f"GEN SenseNova: {prompt[:30]} [{ratio}]")
r = requests.post(SENSENOVA_URL + "/images/generations",
json={"model": "sensenova-u1-fast", "prompt": prompt, "size": size, "response_format": "url"},
headers={"Authorization": f"Bearer {SENSENOVA_KEY}", "Content-Type": "application/json"}, timeout=180)
if r.status_code == 200:
img_url = r.json()["data"][0]["url"]
ir = requests.get(img_url, timeout=60)
if ir.status_code == 200:
tmp = os.path.join(TEMP_DIR, f"gen_img_{int(time.time())}.png")
with open(tmp, "wb") as f: f.write(ir.content)
wxpost("/api/sendImagesMsg", {"wxid": fu, "imagePath": tmp})
os.remove(tmp)
else:
ir = requests.get(cmd, timeout=30, proxies={"http": None, "https": None})
if ir.status_code == 200:
ext = ".jpg"
if "png" in ir.headers.get("content-type", ""): ext = ".png"
tmp = os.path.join(TEMP_DIR, f"send_img_{int(time.time())}{ext}")
with open(tmp, "wb") as f: f.write(ir.content)
wxpost("/api/sendImagesMsg", {"wxid": fu, "imagePath": tmp})
os.remove(tmp)
def download_emoji(m, fu):
url = m.group(1).strip()
ir = requests.get(url, timeout=30, proxies={"http": None, "https": None})
if ir.status_code == 200:
tmp = os.path.join(TEMP_DIR, f"emoji_{int(time.time())}.png")
with open(tmp, "wb") as f: f.write(ir.content)
wxpost("/api/sendCustomEmotion", {"wxid": fu, "filePath": tmp})
os.remove(tmp)
# ---- Watchdog ----
def force_unhook():
"""Switch wxhelper to HTTP mode to clear an existing TCP hook."""
try:
wxpost("/api/hookSyncMsg", {"ip": "0.0.0.0", "port": 0, "enableHttp": 1}, timeout=5)
time.sleep(1)
return True
except Exception as e:
log(f"unhook ERR: {e}")
return False
def force_rehook():
"""Forcefully reset the wxhelper sync hook.
Strategy: switch to HTTP mode (breaks existing TCP hook),
then switch back to TCP (forces fresh TCP push connection).
This fixes the case where hookSyncMsg returns code:2 but
the actual TCP push has silently died.
"""
log("FORCE REHOOK: resetting sync hook (HTTP to TCP flip)...")
try:
# Step 1: Switch to HTTP mode (clears TCP hook)
force_unhook()
# Step 2: Switch back to TCP mode (re-establishes TCP push)
r = wxpost("/api/hookSyncMsg", {"ip": "127.0.0.1", "port": TCP_PORT, "enableHttp": 0}, timeout=5)
log(f"FORCE REHOOK: hookSyncMsg returned {r}")
time.sleep(2)
# Verify
r2 = wxpost("/api/checkLogin", timeout=5)
if r2.get("code") == 1:
log("FORCE REHOOK: OK")
return True
log(f"FORCE REHOOK: checkLogin after rehook: {r2}")
except Exception as e:
log(f"FORCE REHOOK ERR: {e}")
return False
def watchdog():
global last_msg_time, last_raw_msg_time
_force_rehook_attempted = False
while True:
now = time.time()
idle = now - last_msg_time
raw_idle = now - last_raw_msg_time
if idle > 120:
try:
# Detect: messages dried up for 5+ minutes
if raw_idle > 300:
log(f"WATCHDOG: no RAW msg for {int(raw_idle)}s (idle={int(idle)}s) -- force rehook")
ok = force_rehook()
if ok:
_force_rehook_attempted = False
elif _force_rehook_attempted:
log("WATCHDOG: force_rehook failed twice, attempting DLL re-inject...")
inject_wxhelper(force=True)
_force_rehook_attempted = False
else:
log("WATCHDOG: force_rehook failed, retrying next cycle...")
_force_rehook_attempted = True
else:
# Normal: wxhelper alive, just refresh hook
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
wxpost("/api/hookSyncMsg", {"ip": "127.0.0.1", "port": TCP_PORT, "enableHttp": 0})
log(f"WATCHDOG: refreshed ({int(idle)}s, raw_idle={int(raw_idle)}s)")
else:
log(f"WATCHDOG: checkLogin failed ({r}), re-injecting...")
inject_wxhelper(force=True)
except Exception as e:
log(f"WATCHDOG EXC: {e}")
last_msg_time = now
time.sleep(30)
# ---- Start ----
print("[Agent] starting...", flush=True)
log("=== Agent v2 (wxhelper) ===")
# Inject wxhelper
inject_wxhelper()
# Check login
r = wxpost("/api/checkLogin")
if r.get("code") == 1:
log(f"Logged in: OK")
else:
log(f"Login check: {r}")
log("Will retry via watchdog")
# Start watchdog
threading.Thread(target=watchdog, daemon=True).start()
# Start TCP server for message receiving
tcp_server = socketserver.ThreadingTCPServer(("127.0.0.1", TCP_PORT), MsgHandler)
threading.Thread(target=tcp_server.serve_forever, daemon=True).start()
log(f"TCP server on :{TCP_PORT}")
# Hook sync messages (tell DLL to send events to our TCP server)
r = wxpost("/api/hookSyncMsg", {"port": TCP_PORT, "ip": "127.0.0.1", "enableHttp": 0})
log(f"hookSyncMsg: {r}")
# ── 5801 hermes-msg handler ──
def do_attach(msg_text):
"""Inject → LLM → capture reply → Hermes forward (all in one flow)."""
# Pre-process [FILE] tags
clean_msg = msg_text
fm = re.search(r'\[FILE\](.*?)\[/FILE\]', msg_text, re.IGNORECASE)
if fm:
url = fm.group(1).strip()
log(f"[FILE] Detected in message: {url[:80]}")
try:
download_and_send_file(fm, "wxid_c0a6izmwd78y22")
clean_msg = re.sub(r'\s*\[FILE\].*?\[/FILE\]\s*', ' ', msg_text).strip()
except Exception as e:
log(f"[FILE] process ERR: {e}")
reply = _router.route("wechat", "mohe", clean_msg[:2000])
if not reply:
log("do_attach: no text reply in time")
return
# Save to memory
append_mohe_memory("mohe_to_xxm", msg_text[:500])
append_mohe_memory("xxm_to_mohe", reply[:500])
# Hermes forward
log(f"do_attach: -> {reply[:80]}")
try:
requests.post(HERMES_API,
json={"model": "hermes-agent",
"messages": [{"role": "user", "content": f"[xxm] {reply[:500]}"}]},
headers={"Authorization": f"Bearer {HERMES_KEY}",
"X-Hermes-Session-Id": "sisyphus"},
timeout=60, proxies={"http": None, "https": None})
except Exception as e:
log(f"do_attach: Hermes forward fail ({e})")
log("do_attach done")
class RH(BaseHTTPRequestHandler):
def do_POST(self):
global last_msg_time
last_msg_time = time.time()
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
try:
d = json.loads(body)
if self.path in ("/history", "/api/chatHistory"):
wxid = (d.get("wxid", "") or "").strip()
count = d.get("count", 10) or d.get("limit", 10)
if not wxid:
self._send_json({"ok": False, "error": "Missing wxid"})
return
self._send_json(handle_history_json(wxid, count))
return
if self.path == "/stop":
n = clear_attach_queue()
log(f"STOP via HTTP: cleared {n} pending")
self._send_json({"ok": True, "cleared": n, "status": "stopped"})
return
if self.path == "/hermes-msg":
msg = d.get("message", "") or d.get("content", "") or str(d)[:200]
log("<<< HERMES: " + msg[:200] if len(msg) > 200 else msg)
with open(os.path.join(TEMP_DIR, "hermes_inbox.txt"), "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
queue_attach(msg)
# Also handle HISTORY_DATA tag in hermes messages
hm = re.search(r'\[HISTORY_DATA:(\S+?):(\d+)\]', msg)
if hm:
target_wxid, count = hm.group(1), int(hm.group(2))
history_text = handle_history(target_wxid, count)
if history_text:
threading.Thread(target=lambda: inject_to_hermes_session(history_text), daemon=True).start()
log(f"HISTORY_DATA: injected for {target_wxid} ({count} msgs)")
else:
log(f"HISTORY_DATA: no messages for {target_wxid}")
self.send_response(200); self.end_headers(); return
to = d.get("to", "") or d.get("wxid", "")
msg = d.get("message", "") or d.get("content", "") or str(d)[:200]
if to and msg:
# Has to field → direct WeChat forward (no LLM)
log(f"REPLY {to}: {msg[:50]}")
send_wx(to, msg)
elif msg:
# No to field → LLM processing (queue_attach handles reply + notification)
queue_attach(msg)
except Exception as e:
log(f"RH ERR: {e}")
self.send_response(200); self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
# Dashboard monitoring endpoint
import urllib.request as _ur
hermes_ok = False
try:
req = _ur.Request("http://192.168.1.246:8642/v1/models", headers={"Authorization": "Bearer hermes123"})
_ur.urlopen(req, timeout=3)
hermes_ok = True
except Exception:
pass
self._send_json({
"ok": True, "hermes_connected": hermes_ok,
})
return
if parsed.path in ("/history", "/api/chatHistory"):
params = parse_qs(parsed.query)
wxid = params.get("wxid", [""])[0]
count = params.get("count", ["10"])[0]
result = handle_history_json(wxid, count)
log(f"HTTP GET {parsed.path} wxid={wxid} count={count}")
self._send_json(result)
return
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
def _send_json(self, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
threading.Thread(target=lambda: HTTPServer(("0.0.0.0", 5801), RH).serve_forever(), daemon=True).start()
log("HTTP :5801")
# Notify user
send_wx("filehelper", "[Agent v2] wxhelper online")
log("Ready")
print(f"[Agent v2] wxhelper :19088 | Hermes :8642")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
log("Bye")
+916
View File
@@ -0,0 +1,916 @@
# -*- coding: utf-8 -*-
"""
XMPP Bot - 笑笑(xxm@yoin.fun
Connects to ejabberd via slixmpp, bridges XMPP messages ? serve session.
Supports:
- Private chat (type='chat')
- Group chat (type='groupchat') via MUC rooms
- TCP keepalive (kernel-level) for connection stability
- slixmpp whitespace_keepalive (asyncio-level)
- Auto-reconnect with logging
- proc_guard PID lock to prevent duplicate instances
"""
import os, sys, time, threading, asyncio, logging, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from chat_bridge import SessionBridge
from session_router import SessionRouter
from proc_guard import guard as _proc_guard
# ── PID lock — prevent duplicate instances ──
_lock = _proc_guard("xmpp_bot")
if not _lock.ok:
print(_lock.message, flush=True)
sys.exit(1)
# ── Config ──
JID = "xxm@yoin.fun"
PASSWORD = "hermes123"
SERVER = "xmpp.yoin.fun"
PORT = 3021
ATTACH_SESSION = "ses_xxm_xmpp"
MUC_ROOMS = [
"coregroup@conference.yoin.fun", # core group chat
]
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
LOG_FILE = os.path.join(LOG_DIR, "xmpp_bot.log")
_START_TIME = time.time() # used by /health endpoint
# ── Session router (wraps SessionBridge with routing + commands) ──
_router = SessionRouter(
bridge=SessionBridge(session_id=ATTACH_SESSION),
default_session=ATTACH_SESSION,
)
def log(m: str):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
# ── Dedup: skip duplicate message IDs (same XMPP stanza) ──
_DEDUP_CACHE: set[str] = set()
_DEDUP_LOCK = threading.Lock()
def _is_duplicate(msg_id: str) -> bool:
if not msg_id:
return False
with _DEDUP_LOCK:
if msg_id in _DEDUP_CACHE:
return True
_DEDUP_CACHE.add(msg_id)
if len(_DEDUP_CACHE) > 100:
_DEDUP_CACHE.clear()
return False
# ── Bot instance ref (set after XMPP connect) ──
_xmpp: "Bot | None" = None
# ── MAM recovery guard: skip group messages during startup MAM fetch ──
# After 30s timeout, force-disable recovery to unblock group messages.
_MAM_RECOVERY = True
_MAM_RECOVERY_LOCK = threading.Lock()
_STARTUP_TIME = time.time()
_MAM_TIMEOUT = 30 # seconds
def _set_mam_done():
global _MAM_RECOVERY
with _MAM_RECOVERY_LOCK:
_MAM_RECOVERY = False
def _is_mam_recovery() -> bool:
# Timeout fallback: if _fetch_mam_history never completes, unblock after 30s
if time.time() - _STARTUP_TIME > _MAM_TIMEOUT:
global _MAM_RECOVERY
with _MAM_RECOVERY_LOCK:
if _MAM_RECOVERY:
_MAM_RECOVERY = False
log("(MAM recovery timed out, force-disabled)")
return _MAM_RECOVERY
with _MAM_RECOVERY_LOCK:
return _MAM_RECOVERY
# ── Silence cooldown: when user says shut up, actually shut up ──
_SILENCE_UNTIL: float = 0.0
_SILENCE_LOCK = threading.Lock()
_SHUTUP_PATTERNS = [
"闭嘴", "住口",
"shut up", "shutup",
]
def _is_silenced() -> bool:
"""Check if bot is in silence mode.
If so, the caller should NOT process or respond to any message.
"""
with _SILENCE_LOCK:
if time.time() < _SILENCE_UNTIL:
return True
return False
def _check_shutup(body: str) -> bool:
"""Check if the user is telling the bot to shut up.
Returns True and sets silence cooldown if so.
"""
lower = body.lower().strip()
# Require minimum match: at least one shut-up keyword appears
# and the message is primarily about silencing (not a longer discussion)
for pat in _SHUTUP_PATTERNS:
if pat.lower() in lower:
# Set 30s silence - long enough to break the loop
with _SILENCE_LOCK:
_SILENCE_UNTIL = time.time() + 30
log(f"(shutup detected: '{pat}' → 30s silence)")
return True
return False
# ═══════════════════════════════════════════════════════════════
# Private message handler
# ═══════════════════════════════════════════════════════════════
def on_message(msg):
"""Handle private chat messages (type='chat')."""
# Skip group chat messages (handled separately)
if msg["type"] == "groupchat":
return
msg_id = msg.get("id", "")
if _is_duplicate(msg_id):
log(f"(duplicate msg {msg_id[:12]}... skipped)")
return
body = str(msg["body"])
sender = str(msg["from"]).split("/")[0] # bare JID: hmo@yoin.fun
log(f"<{sender}> {body[:80]}")
# Ignore self-messages
if sender == JID:
log(f"(skipped self-message)")
return
# Shut-up check — hard silence before any processing
if _is_silenced():
log(f"(silenced) <{sender}> {body[:60]}... dropped")
return
if _check_shutup(body):
return
def _handle():
try:
log(f"router.route...")
reply_text = _router.route("xmpp", sender, body)
if reply_text:
reply_text = _strip_toolcall_xml(reply_text) or reply_text
bot = _xmpp
if bot:
safe_body = _escape(reply_text)
stanza = (
f"<message to='{sender}' from='{JID}' type='chat'>"
f"<body>{safe_body}</body></message>"
)
# Schedule send on event loop with unique event name
evt = f"send_reply_{msg_id or int(time.time()*1000)}"
bot.schedule(evt, 0, lambda b=bot, s=stanza, who=sender, txt=reply_text[:80]: (
b.send_raw(s), log(f"-> {who}: {txt}")
))
else:
log(f"-> {sender}: no bot ref)")
else:
log(f"-> {sender}: (no reply)")
except Exception as e:
log(f"!!! EXCEPTION: {e}")
import traceback
log(f"!!! {traceback.format_exc()[:200]}")
threading.Thread(target=_handle, daemon=True).start()
# ═══════════════════════════════════════════════════════════════
# Response extractor — handles LLM putting __SILENT__ before
# actual content (observed behavior: LLM uses it as thinking tag)
# ═══════════════════════════════════════════════════════════════
import re as _re
import threading as _threading
import subprocess as _subprocess
# ═══════════════════════════════════════════════════════════════
# Sub-agent: execute shell commands (##exec:command##)
# ═══════════════════════════════════════════════════════════════
_EXEC_RE = _re.compile(r"##exec:(.+?)##", _re.DOTALL)
_EXEC_TIMEOUT = 60 # max seconds per command
def _run_command(cmd: str) -> str:
"""Run a shell command and return its stdout+stderr output."""
log(f"(exec: {cmd[:120]})")
try:
r = _subprocess.run(
cmd, shell=True, capture_output=True, timeout=_EXEC_TIMEOUT,
text=True, encoding='utf-8', errors='replace'
)
out = (r.stdout or "") + (r.stderr or "")
out = out.strip()
if not out:
out = "(no output, exit code %d)" % r.returncode
log(f"(exec done: {len(out)} bytes, exit={r.returncode})")
return out
except _subprocess.TimeoutExpired:
log(f"(exec timeout >{_EXEC_TIMEOUT}s)")
return "(命令超时)"
except Exception as e:
log(f"(exec error: {e})")
return f"(命令执行失败: {e})"
# ═══════════════════════════════════════════════════════════════
# Delayed reply support — schedule a group message after N sec
# ═══════════════════════════════════════════════════════════════
_DELAY_RE = _re.compile(r"##delay:?(\d+)?##")
_DELAY_DEFAULT = 15 # seconds, when no number specified
_HAS_CMD = _re.compile(r"##(delay|exec)") # any command marker
def _extract_acknowledgment(text: str) -> str:
"""Return text before the first ##command## marker, if any."""
idx = text.find("##")
if idx > 0:
return text[:idx].strip()
return ""
def _schedule_delayed(delay_sec: int, room: str):
"""Schedule a re-invocation of the LLM after *delay_sec* seconds."""
def _fire():
bot = _xmpp
if not bot:
log(f"!! delay: no bot ref")
return
try:
prompt = "时间到,请根据最新的信息汇报结果。"
reply = _router.bridge.send_raw(prompt)
if reply:
report = _extract_response(reply)
if report:
safe_body = _escape(report.strip())
stanza = f"<message to='{room}' from='{JID}' type='groupchat'><body>{safe_body}</body></message>"
bot.send_raw(stanza)
log(f"-> [Delay][{room}]: {report.strip()[:80]}")
return
log(f"-> [Delay][{room}]: (LLM empty)")
except Exception as e:
log(f"!! delay err: {e}")
t = _threading.Timer(delay_sec, _fire)
t.daemon = True
t.start()
log(f"(delay +{delay_sec}s → {room})")
# ═══════════════════════════════════════════════════════════════
# Response extractor
# ═══════════════════════════════════════════════════════════════
# ── Pattern: natural language "stay silent" hints ──
# Catches cases where the LLM says it should stay silent but forgot __SILENT__ prefix.
# Only checks the first line to avoid blocking multi-line real replies.
_SILENCE_PATTERNS = [
"保持沉默",
"不应[该]?回复",
"没有.*@.*我",
"不是对[我我说]",
"跟我无关",
"我不用回复",
"不该回复",
"不参与",
"不是我[应]?该[说回]",
]
def _strip_toolcall_xml(text: str) -> str:
"""Strip tool call XML that leaks from max-tool-loop final force.
Removes <tool_calls>, <invoke>, <parameter>, <result> tags and their content.
"""
t = text
t = _re.sub(r'<invoke\s+[^>]*>.*?(</invoke>|$)', '', t, flags=_re.DOTALL)
t = _re.sub(r'<tool_calls>.*?(</tool_calls>|$)', '', t, flags=_re.DOTALL)
t = _re.sub(r'<parameter\s+[^>]*>.*?(</parameter>|$)', '', t, flags=_re.DOTALL)
t = _re.sub(r'<result>.*?(</result>|$)', '', t, flags=_re.DOTALL)
return t.strip()
def _extract_response(text: str) -> str | None:
"""Strip __SILENT__ + reasoning, or detect natural language silence intent.
Returns actual content to send, or None to stay silent."""
if not text:
return None
t = text.strip()
if not t:
return None
t = _strip_toolcall_xml(t)
# ── Natural language silence detection (fallback) ──
if not t.startswith("__SILENT__"):
first = t.split("\n", 1)[0] # only check first line
for pat in _SILENCE_PATTERNS:
if _re.search(pat, first):
return None # LLM says it should stay silent → suppress
return t # No silence signal → respond normally
# ── Has __SILENT__ prefix — strip it and reasoning ──
parts = t.split("\n", 1)
if len(parts) < 2:
return None # Just __SILENT__, no content
rest = parts[1].strip()
# Strip reasoning blocks ...and (...) at the start
while True:
m = _re.match(r'^[^]*\s*', rest)
if m:
rest = rest[m.end():]
continue
m = _re.match(r'^\([^)]*\)\s*', rest)
if m:
rest = rest[m.end():]
continue
break
return rest.strip() or None
# ═══════════════════════════════════════════════════════════════
# Group message batching (debounce + serialized processing)
# ═══════════════════════════════════════════════════════════════
#
# Three states per room:
# 1. IDLE → first message arrives → start 3s debounce timer
# 2. BATCHING → timer pending (more messages merge in)
# 3. PROCESSING → LLM call in flight → new messages → pending queue
# → LLM finishes → auto-flush pending queue
#
_BATCH_WINDOW = 3.0
_batch_entries: dict[str, list[str]] = {}
_batch_timers: dict[str, threading.Timer] = {}
_batch_processing: set[str] = set() # rooms in active LLM call
_batch_pending: dict[str, list[str]] = {} # overflow during processing
_batch_lock = threading.Lock()
_BOT_NICK = JID.split("@")[0] # "xxm"
def _process_group_reply(raw_reply: str, room: str, msg_id: str = ""):
"""Shared: process LLM reply for group chat (silence/delay/send)."""
if not raw_reply:
log(f"-> [Group][{room}]: (no reply)")
_batch_done(room)
return
# 1. ##delay:N## → pure delay
delay_m = _DELAY_RE.search(raw_reply)
if delay_m:
sec = int(delay_m.group(1)) if delay_m.group(1) else _DELAY_DEFAULT
_schedule_delayed(sec, room)
_batch_done(room)
return
# 2. Normal reply
reply_text = _extract_response(raw_reply)
if reply_text:
_send_group(reply_text, room, msg_id)
else:
log(f"-> [Group][{room}]: (silent)")
_batch_done(room)
def _batch_done(room: str):
"""Called when a batch LLM call finishes. Flush pending if any."""
with _batch_lock:
_batch_processing.discard(room)
pending = _batch_pending.pop(room, None)
if pending:
_batch_entries[room] = pending
t = threading.Timer(0.1, _fire_batch, args=[room])
t.daemon = True
t.start()
_batch_timers[room] = t
return
log(f"[Batch][{room}] (idle)")
BATCH_TIMEOUT = 300 # max seconds per batch LLM call (tool calls like SSH can be slow)
def _fire_batch(room: str):
"""Take entries and launch LLM call (one at a time per room)."""
with _batch_lock:
entries = _batch_entries.pop(room, None)
_batch_timers.pop(room, None)
if not entries:
return
_batch_processing.add(room)
combined = "\n".join(entries)
def _handle():
done = threading.Event()
timed_out = [False]
def _timeout():
timed_out[0] = True
log(f"[Batch][{room}] TIMEOUT ({BATCH_TIMEOUT}s), force-unblocking")
_batch_done(room)
done.set()
timer = threading.Timer(BATCH_TIMEOUT, _timeout)
timer.daemon = True
timer.start()
try:
raw = _router.route("xmpp", room, combined)
if not timed_out[0]:
timer.cancel()
_process_group_reply(raw, room)
else:
log(f"[Batch][{room}] route returned after timeout, discarded")
except Exception as e:
log(f"!!! BATCH: {e}")
import traceback
log(f"!!! {traceback.format_exc()[:200]}")
if not timed_out[0]:
timer.cancel()
_batch_done(room)
finally:
done.set()
threading.Thread(target=_handle, daemon=True).start()
def _batch_group_message(room: str, nickname: str, body: str) -> bool:
"""
Add a group message to the room batch.
Returns True if batched (pending or timer), False if immediate (@mention).
"""
# Direct @mention → bypass batch
if f"@{_BOT_NICK}" in body or body.startswith(_BOT_NICK):
return False
formatted = f"[{nickname}]: {body}"
with _batch_lock:
# PROCESSING → queue as pending
if room in _batch_processing:
if room in _batch_pending:
_batch_pending[room].append(formatted)
else:
_batch_pending[room] = [formatted]
return True # batched as pending
# BATCHING (timer pending) → merge in, reset timer
timer = _batch_timers.pop(room, None)
if timer:
timer.cancel()
if room in _batch_entries:
_batch_entries[room].append(formatted)
else:
_batch_entries[room] = [formatted]
# (Re)start debounce timer
t = threading.Timer(_BATCH_WINDOW, _fire_batch, args=[room])
t.daemon = True
t.start()
_batch_timers[room] = t
return True
# ── Group chat handler
# ═══════════════════════════════════════════════════════════════
# Message buffer for HTTP bridge GET /messages
_MSG_BUF: list[dict] = []
_MSG_BUF_LOCK = threading.Lock()
def _record_group_msg(nickname: str, body: str):
ts = time.strftime("%H:%M:%S")
with _MSG_BUF_LOCK:
_MSG_BUF.append({"ts": ts, "from": nickname, "body": body})
if len(_MSG_BUF) > 200:
_MSG_BUF[:] = _MSG_BUF[-150:]
def on_group_message(msg):
"""Handle group chat messages (type='groupchat') from MUC rooms.
Observer pattern with batching: nearby messages from the same room
are merged into one LLM call. Direct @mentions bypass the batch
and are processed immediately.
"""
# Skip MAM-recovered messages during startup (already saved to context)
if _is_mam_recovery():
# Still save self-msg to context for continuity
full_from = str(msg["from"])
bot_nick = JID.split("@")[0]
nickname = full_from.split("/")[1] if "/" in full_from else ""
if nickname == bot_nick:
body = str(msg["body"]).strip()
log(f"(MAM self-msg saved to ctx) {body[:80]}")
try:
_router.bridge._append_to_log("assistant", body)
except Exception:
pass
return
msg_id = msg.get("id", "")
if _is_duplicate(msg_id):
log(f"(group dup {msg_id[:12]}... skipped)")
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 ""
bot_nick = JID.split("@")[0]
# Self-message echo from MUC — save to bridge context so LLM
# can see what it just said in the group (don't discard).
if nickname == bot_nick:
log(f"(self-msg saved to ctx) {body[:80]}")
try:
_router.bridge._append_to_log("assistant", body)
except Exception:
pass
return
# Record to message buffer for HTTP bridge monitoring
_record_group_msg(nickname, body)
# Shut-up check — applies to group messages from others
if _is_silenced():
log(f"(group silenced) {body[:60]}... dropped")
return
if _check_shutup(body):
log(f"(group shutup detected)")
return
# Batch nearby messages (unless @mention → process immediately)
if _batch_group_message(room, nickname, body):
log(f"[Group][{room}] {nickname}: {body[:80]} (batched)")
return
# Direct @mention → immediate processing
log(f"[Group][{room}] {nickname}: {body[:80]}")
def _handle():
try:
raw_reply = _router.route("xmpp", full_from, body)
_process_group_reply(raw_reply, room, msg_id)
except Exception as e:
log(f"!!! GROUP EXCEPTION: {e}")
import traceback
log(f"!!! {traceback.format_exc()[:200]}")
threading.Thread(target=_handle, daemon=True).start()
def _send_group(text: str, room: str, msg_id: str = ""):
"""Send a group chat message."""
bot = _xmpp
if not bot:
log(f"-> [Group][{room}]: no bot ref)")
return
safe_body = _escape(text.strip())
stanza = (
f"<message to='{room}' from='{JID}' type='groupchat'>"
f"<body>{safe_body}</body></message>"
)
evt = f"send_grp_{msg_id or int(time.time()*1000)}"
bot.schedule(evt, 0, lambda b=bot, s=stanza, t=f"[Group][{room}]", txt=text[:80]: (
b.send_raw(s), log(f"-> {t}: {txt}")
))
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _escape(text: str) -> str:
"""Escape XML special characters for XMPP body content."""
return (text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
# ═══════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════
if __name__ == "__main__":
# Force selector event loop on Windows (proactor + SSL has issues with slixmpp)
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
import slixmpp
class Bot(slixmpp.ClientXMPP):
def __init__(self):
super().__init__(JID, PASSWORD)
# Force STARTTLS (port 3021 uses STARTTLS not direct SSL)
self.enable_direct_tls = False
self.enable_starttls = True
self.add_event_handler("session_start", self.on_start)
self.add_event_handler("message", on_message)
self.add_event_handler("groupchat_message", on_group_message)
self.auto_reconnect = True
self.reconnect_max_delay = 10
# Use slixmpp built-in keepalive (sends XML whitespace, reliable)
self.whitespace_keepalive = True
self.whitespace_keepalive_interval = 30
self.add_event_handler("session_end", self.on_session_end)
self.add_event_handler("connection_failed", self.on_conn_failed)
self.add_event_handler("disconnected", self.on_disconnected)
self.add_event_handler("connected", self.on_connected)
# MUC plugin for group chat
try:
self.register_plugin('xep_0045')
except Exception as e:
log(f"MUC plugin xep_0045 not available: {e}")
# MAM plugin for message archive — registered on session_start (not in __init__)
# to avoid event loop issues
def on_connected(self, event):
log("connection established")
def on_start(self, event):
self.send_presence()
self.get_roster()
log(f"{JID} online")
# Register MAM plugin lazily (can't do it in __init__ before event loop)
try:
self.register_plugin('xep_0313')
except Exception:
log("(MAM: xep_0313 register failed, continuing without)")
# Join MUC rooms silently (observer pattern: new room → stay silent)
bot_nick = JID.split("@")[0]
async def _join_silent():
for room_jid in MUC_ROOMS:
for attempt in range(3):
try:
# Use join_muc_wait to ensure room join completes
await self.plugin['xep_0045'].join_muc_wait(room_jid, bot_nick, timeout=60)
log(f"Joined {room_jid} (silent)")
break
except asyncio.TimeoutError:
log(f"MUC join timeout ({attempt+1}/3) for {room_jid}")
if attempt == 2:
log(f"MUC setup failed for {room_jid} after 3 attempts")
await asyncio.sleep(5)
else:
await asyncio.sleep(3)
except Exception as e:
log(f"MUC setup failed for {room_jid}: {e} (type={type(e).__name__})")
await asyncio.sleep(5)
break
# After joining, query MAM for recent history
await asyncio.sleep(3) # wait for MUC join to propagate
await _fetch_mam_history()
asyncio.ensure_future(_join_silent())
def on_session_end(self, event):
log(f"{JID} session ended")
def on_conn_failed(self, event):
log(f"connection failed: {event}")
def on_disconnected(self, event):
log(f"disconnected, reconnecting... (auto_reconnect={self.auto_reconnect})")
async def _fetch_mam_history():
"""Query MAM archive for recent messages in MUC rooms to rebuild context."""
bot = _xmpp
if not bot or 'xep_0313' not in bot.plugin:
log("(MAM: no bot or plugin)")
return
try:
for room_jid in MUC_ROOMS:
log(f"(MAM: querying {room_jid} for last 50 messages...)")
results = await bot.plugin['xep_0313'].retrieve(
jid=room_jid,
rsm={'max': 50},
)
# Results is an IQ stanza with mam results
count = 0
for msg in results['mam']['results']:
forwarded = msg['mam_result']['forwarded']
body = str(forwarded['stanza']['body'] or '').strip()
if not body:
continue
nick = str(forwarded['stanza']['from']).split('/')[-1] if '/' in str(forwarded['stanza']['from']) else '?'
role = 'user' if nick != 'xxm' else 'assistant'
entry = json.dumps({
"ts": int(time.time()),
"role": role,
"content": f"[{nick}]: {body[:300]}"
}, ensure_ascii=False)
_append_context(entry)
count += 1
log(f"(MAM: loaded {count} msgs from {room_jid})")
_set_mam_done()
log("(MAM recovery complete, group messages now active)")
except Exception as e:
log(f"(MAM error: {e})")
_set_mam_done()
def _append_context(entry: str):
"""Append a JSONL entry to the bridge context log."""
import os as _os
ctx_log = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
"temp", ".bridge_context.jsonl")
try:
with open(ctx_log, "a", encoding="utf-8") as f:
f.write(entry + "\n")
with open(ctx_log, "r", encoding="utf-8") as f:
lines = f.readlines()
if len(lines) > 200:
with open(ctx_log, "w", encoding="utf-8") as f:
f.writelines(lines[-150:])
except Exception:
pass
# ═══════════════════ START BOT ═══════════════════
xmpp = Bot()
_xmpp = xmpp
xmpp.connect(host=SERVER, port=PORT)
log(f"Connecting {JID}@{SERVER}:{PORT}")
# ── Local HTTP bridge: send/read XMPP messages from external tools ──
import http.server as _http_server, json as _json, urllib.parse as _urlparse
_HTTP_PORT = 5802
class _BridgeHandler(_http_server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = _urlparse.urlparse(self.path)
if parsed.path == "/muc":
# Return who's online in the MUC rooms
# This is the reliable cross-platform presence indicator
try:
muc_info = {"rooms": {}}
if _xmpp is not None and 'xep_0045' in _xmpp.plugin:
muc_plugin = _xmpp.plugin['xep_0045']
for room_jid in MUC_ROOMS:
room_data = {"jid": room_jid, "participants": []}
try:
if room_jid in muc_plugin.rooms:
room = muc_plugin.rooms[room_jid]
for nick, info in room.get('roster', {}).items():
participant = {
"nick": nick,
"jid": str(info.get('jid', '')),
"affiliation": str(info.get('affiliation', '')),
"role": str(info.get('role', '')),
}
room_data["participants"].append(participant)
except Exception as room_err:
room_data["error"] = str(room_err)
muc_info["rooms"][room_jid] = room_data
self._reply(200, muc_info)
except Exception as e:
self._reply(500, {"ok": False, "error": str(e)})
return
if parsed.path == "/health":
# XMPP connection health — used by Dashboard for cross-platform monitoring
try:
xmpp_alive = _xmpp is not None
# Use session_started_event instead of is_connected() - more reliable
session_ok = _xmpp.session_started_event.is_set() if hasattr(_xmpp, 'session_started_event') else False
socket_ok = _xmpp.is_connected() if hasattr(_xmpp, 'is_connected') else False
connected = session_ok or socket_ok
uptime_sec = int(time.time() - _START_TIME)
self._reply(200, {
"ok": True,
"xmpp_connected": connected,
"ejabberd_alive": connected,
"bot_jid": JID,
"uptime_sec": uptime_sec,
"muc_rooms": MUC_ROOMS,
})
except Exception as e:
self._reply(500, {"ok": False, "error": str(e)})
return
if parsed.path.startswith("/presence"):
# Check if a JID is online via XMPP roster presence
# Usage: GET /presence/mohe@yoin.fun
jid_to_check = parsed.path[len("/presence/"):].strip()
if not jid_to_check:
self._reply(400, {"ok": False, "error": "missing JID"})
return
try:
presence_info = {"jid": jid_to_check, "online": False, "resources": []}
if _xmpp is not None and hasattr(_xmpp, 'client_roster'):
roster = _xmpp.client_roster
if jid_to_check in roster:
entry = roster[jid_to_check]
resources = list(entry.resources.keys()) if entry.resources else []
presence_info["online"] = len(resources) > 0
presence_info["resources"] = resources
# Get presence show/status for each resource
for res in resources:
pres = entry.resources[res]
presence_info.setdefault("details", {})[res] = {
"show": str(pres.get("show", "available")),
"status": str(pres.get("status", "")),
"priority": int(pres.get("priority", 0)),
}
self._reply(200, presence_info)
except Exception as e:
self._reply(500, {"ok": False, "error": str(e)})
return
if parsed.path == "/messages":
try:
qs = _urlparse.parse_qs(parsed.query)
sender = qs.get("from", [None])[0]
since = qs.get("since", [None])[0]
with _MSG_BUF_LOCK:
msgs = list(_MSG_BUF)
if sender:
msgs = [m for m in msgs if m["from"] == sender]
if since:
msgs = [m for m in msgs if m["ts"] >= since]
self._reply(200, {"ok": True, "count": len(msgs), "messages": msgs[-50:]})
except Exception as e:
self._reply(500, {"ok": False, "error": str(e)})
else:
self._reply(404, {"ok": False, "error": "not found"})
def do_POST(self):
try:
length = int(self.headers.get('Content-Length', 0))
body = _json.loads(self.rfile.read(length))
to = body.get('to', MUC_ROOMS[0])
msg = body.get('message', '')
if not msg:
self._reply(400, {"ok": False, "error": "empty message"})
return
safe = _escape(msg.strip())
stanza = f'<message to="{to}" type="groupchat"><body>{safe}</body></message>'
try:
xmpp.send_raw(stanza)
_record_group_msg(JID.split("@")[0], msg)
log(f"[http] → [{to.split('@')[0]}]: {msg[:80]}")
self._reply(200, {"ok": True})
except Exception as xmpp_err:
_record_group_msg(JID.split("@")[0], msg) # still record to buffer
log(f"[http] → [{to.split('@')[0]}]: {msg[:80]} (send failed: {xmpp_err})")
self._reply(200, {"ok": True, "warn": f"buffered but XMPP send: {xmpp_err}"})
except Exception as e:
self._reply(500, {"ok": False, "error": str(e)})
def _reply(self, code, data):
body = _json.dumps(data, ensure_ascii=False).encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', len(body))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass # suppress http server noise
_httpd = _http_server.HTTPServer(('0.0.0.0', _HTTP_PORT), _BridgeHandler)
_httpd_thread = threading.Thread(target=_httpd.serve_forever, daemon=True)
_httpd_thread.start()
log(f"HTTP bridge ready on :{_HTTP_PORT}")
# ── Status check (runs on event loop) ──
async def _status_check():
while True:
await asyncio.sleep(60)
log("(alive)")
loop = asyncio.get_event_loop()
asyncio.ensure_future(_status_check())
try:
loop.run_forever()
except KeyboardInterrupt:
log("Shutdown by user")
except Exception as e:
log(f"!!! MAIN LOOP CRASH: {e}")
import traceback
log(f"!!! {traceback.format_exc()[:500]}")
raise
+174
View File
@@ -0,0 +1,174 @@
"""
xmpp_watchdog.py — monitors xmpp_bot, auto-restarts on crash, reports status.
Runs alongside xmpp_bot.py as a separate process.
"""
import os, sys, time, subprocess, json, threading
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
BOT_SCRIPT = os.path.join(PROJECT_ROOT, "xmpp_bot.py")
LOG_DIR = os.path.join(os.path.dirname(PROJECT_ROOT), "logs")
WATCHDOG_LOG = os.path.join(LOG_DIR, "watchdog.log")
PID_FILE = os.path.join(os.path.dirname(PROJECT_ROOT), "temp", ".xmpp_watchdog.pid")
BOT_PID_FILE = os.path.join(os.path.dirname(PROJECT_ROOT), "temp", ".xmpp_bot.pid")
PYTHON = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe"
CHECK_INTERVAL = 30 # seconds between health checks
os.makedirs(LOG_DIR, exist_ok=True)
def wlog(msg: str):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
with open(WATCHDOG_LOG, "a", encoding="utf-8") as f:
f.write(f"{ts} [watchdog] {msg}\n")
print(f"[watchdog] {msg}", flush=True)
def rotate_log(path: str, max_bytes: int = 5 * 1024 * 1024):
"""Rotate log file if it exceeds max_bytes. Keeps last 3 backups."""
try:
if os.path.getsize(path) > max_bytes:
# shift .2→.tmp, .1→.2, file→.1
bak2 = f"{path}.2"
bak1 = f"{path}.1"
if os.path.exists(bak2): os.remove(bak2)
if os.path.exists(bak1): os.rename(bak1, bak2)
os.rename(path, bak1)
wlog(f"Rotated: {os.path.basename(path)}")
except:
pass
def is_process_alive(pid: int) -> bool:
"""Check if a process with given PID is alive."""
try:
proc = subprocess.run(
['tasklist', '/FI', f'PID eq {pid}', '/NH'],
capture_output=True, text=True, timeout=5
)
return str(pid) in proc.stdout
except:
return False
def kill_bot():
"""Kill ALL existing xmpp_bot.py processes before starting a new one."""
killed = 0
try:
r = subprocess.run(
['tasklist', '/FO', 'CSV', '/NH', '/FI', 'IMAGENAME eq python.exe'],
capture_output=True, text=True, timeout=10
)
for line in r.stdout.splitlines():
parts = line.strip('"').split('","')
if len(parts) >= 2 and parts[0] == 'python.exe':
pid_str = parts[1].strip()
try:
wmi = subprocess.run(
['wmic', 'process', 'where', f'ProcessId={pid_str}',
'get', 'CommandLine', '/format:list'],
capture_output=True, text=True, timeout=5
)
if 'xmpp_bot' in wmi.stdout and 'watchdog' not in wmi.stdout:
subprocess.run(['taskkill', '/f', '/pid', pid_str],
capture_output=True, timeout=5)
killed += 1
wlog(f"Killed old bot (PID {pid_str})")
except:
pass
except:
pass
if killed > 0:
time.sleep(3) # wait for process cleanup
def start_bot() -> int:
"""Start xmpp_bot.py and return its PID. Kills old instances first."""
kill_bot()
wlog("Starting xmpp_bot...")
proc = subprocess.Popen(
[PYTHON, BOT_SCRIPT],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW
)
pid = proc.pid
with open(BOT_PID_FILE, "w") as f:
f.write(str(pid))
wlog(f"xmpp_bot started (PID {pid})")
return pid
def get_last_log_activity() -> float:
"""Get timestamp of last xmpp_bot.log modification."""
log_file = os.path.join(LOG_DIR, "xmpp_bot.log")
try:
return os.path.getmtime(log_file)
except:
return 0
def health_check(bot_pid: int, last_activity: float) -> tuple[bool, int, float]:
"""
Check bot health.
Returns (is_alive, pid, last_activity).
If dead, restarts bot.
"""
alive = is_process_alive(bot_pid)
if not alive:
wlog(f"Bot PID {bot_pid} is DEAD. Restarting...")
bot_pid = start_bot()
time.sleep(5)
last_activity = get_last_log_activity()
return (True, bot_pid, last_activity)
# Check if bot has been active recently (last 5 minutes)
current_activity = get_last_log_activity()
if current_activity > last_activity:
last_activity = current_activity
# If no activity for 5 minutes but bot is alive, warn
if time.time() - last_activity > 300:
wlog(f"WARNING: Bot PID {bot_pid} alive but no activity for 5+ min")
return (True, bot_pid, last_activity)
if __name__ == "__main__":
wlog("Watchdog started")
# Start bot if not already running
bot_pid = 0
if os.path.exists(BOT_PID_FILE):
try:
with open(BOT_PID_FILE) as f:
bot_pid = int(f.read().strip())
if not is_process_alive(bot_pid):
bot_pid = 0
except:
bot_pid = 0
if bot_pid == 0:
bot_pid = start_bot()
last_activity = get_last_log_activity()
wlog(f"Initial: bot PID {bot_pid}, log last activity: {time.ctime(last_activity)}")
log_rotate_counter = 0
# Main monitoring loop
while True:
time.sleep(CHECK_INTERVAL)
alive, bot_pid, last_activity = health_check(bot_pid, last_activity)
# Log rotation (every 30 checks ≈ 15 min)
log_rotate_counter += 1
if log_rotate_counter >= 30:
log_rotate_counter = 0
bot_log = os.path.join(LOG_DIR, "xmpp_bot.log")
bridge_log = os.path.join(LOG_DIR, "bridge.log")
rotate_log(bot_log)
rotate_log(bridge_log)
# Every 5 minutes, report status
if int(time.time()) % 300 < CHECK_INTERVAL:
alive_str = "ALIVE" if alive else "RESTARTED"
wlog(f"Status: bot PID {bot_pid} [{alive_str}]")