Initial: multi-agent XMPP communication system with dashboard
- Platform-based architecture (Windows/Linux/Mac) - Agent instance registry (agents.yaml) - Management dashboard with cross-platform monitoring - xmpp_bot with HTTP bridge + health endpoints - wechat_agent with WeChat-Hermes bridging - Platform services: ProcessGuardian, HealthProbe, APIRouter, ChannelBridge - Deployment: systemd (Linux) + PowerShell (Windows) - Monitoring: SSH+ejabberdctl for cross-platform presence
This commit is contained in:
@@ -0,0 +1,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 200(opencode retry-cache 永不触发)")
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log.info("收到中断信号,关闭服务器...")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,'&').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)} · ${esc(a.host)} ${a.pid ? '· 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) ? '▲' : '▼'}</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>
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 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
|
||||
@@ -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}]")
|
||||
Reference in New Issue
Block a user