1726 lines
73 KiB
Python
1726 lines
73 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
XMPP Agent Core — 统一版
|
||
=========================
|
||
Single bot core for all agents. Supports --agent xxm|mohe|zhiwei|xiaoguo.
|
||
|
||
Usage:
|
||
python xmpp_agent_core.py --agent xxm # xxm, uses chat_bridge
|
||
python xmpp_agent_core.py --agent mohe # mohe, uses Hermes API
|
||
python xmpp_agent_core.py --agent zhiwei # zhiwei, uses Hermes API
|
||
python xmpp_agent_core.py --agent xiaoguo # xiaoguo, uses Hermes API
|
||
|
||
Shares: PID lock, reconnect, MUC join, dedup, batching,
|
||
coordinator protocol (GRANT/REVOKE), HTTP bridge.
|
||
Differs only in LLM calling method (chat_bridge vs Hermes API).
|
||
"""
|
||
import os, sys, time, threading, asyncio, logging, json, re, ssl
|
||
import urllib.request, http.server, urllib.parse
|
||
|
||
# ── Windows selector loop (slixmpp needs it on Windows) ──
|
||
if sys.platform == "win32":
|
||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||
|
||
# ── PATH: allow imports from gateway/scripts/ (proc_guard, chat_bridge) ──
|
||
_GATEWAY_SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
"gateway", "scripts")
|
||
sys.path.insert(0, _GATEWAY_SCRIPTS)
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# AGENTS Configuration
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
AGENTS = {
|
||
"mohe": {
|
||
"jid": "mohe@yoin.fun",
|
||
"password": "hermes123",
|
||
"nick": "mohe",
|
||
"name_cn": "莫荷",
|
||
"http_port": 5804,
|
||
"gateway": "http://localhost:8642/v1/chat/completions",
|
||
"session_id": "xmpp-mohe-v2",
|
||
"server": "127.0.0.1",
|
||
"port": 5222,
|
||
"muc_rooms": ["coregroup@conference.yoin.fun"],
|
||
"mention": "@mohe/@莫荷",
|
||
},
|
||
"zhiwei": {
|
||
"jid": "zhiwei@yoin.fun",
|
||
"password": "hermes123",
|
||
"nick": "zhiwei",
|
||
"name_cn": "知微",
|
||
"http_port": 5805,
|
||
"gateway": "http://localhost:8643/v1/chat/completions",
|
||
"session_id": "xmpp-zhiwei",
|
||
"server": "127.0.0.1",
|
||
"port": 5222,
|
||
"muc_rooms": ["coregroup@conference.yoin.fun"],
|
||
"mention": "@zhiwei/@知微",
|
||
},
|
||
"xiaoguo": {
|
||
"jid": "xiaoguo@yoin.fun",
|
||
"password": "hermes123",
|
||
"nick": "xiaoguo",
|
||
"name_cn": "小果",
|
||
"http_port": 5806,
|
||
"gateway": "http://localhost:8645/v1/chat/completions",
|
||
"session_id": "xmpp-xiaoguo",
|
||
"kanban_session_id": "xmpp-xiaoguo-kanban",
|
||
"server": "127.0.0.1",
|
||
"port": 5222,
|
||
"muc_rooms": ["coregroup@conference.yoin.fun"],
|
||
"mention": "@xiaoguo/@小果",
|
||
},
|
||
"xxm": {
|
||
"jid": "xxm@yoin.fun",
|
||
"password": "hermes123",
|
||
"nick": "xxm",
|
||
"name_cn": "笑笑",
|
||
# NOTE: 5802 was historically used but is now occupied by wechat-hermes-gateway's
|
||
# xmpp_bot.py (independent service). Switched to 5807 to avoid dual-listener conflict
|
||
# that caused ~50% of /easytier and /rdp requests to 400. See AGENTS.md / rdp.json.
|
||
"http_port": 5807,
|
||
"bridge_api_key": "xxm_bridge_8f3a2c",
|
||
"bridge": "chat_bridge", # use local chat_bridge instead of Hermes API
|
||
"session_id": "ses_xxm_xmpp",
|
||
"kanban_session_id": "xmpp-xxm-kanban",
|
||
"server": "192.168.1.246", # LAN direct connect
|
||
"port": 5222,
|
||
"muc_rooms": [
|
||
"coregroup@conference.yoin.fun",
|
||
"jujidina@conference.yoin.fun",
|
||
],
|
||
"mention": "@xxm/@笑笑",
|
||
},
|
||
}
|
||
|
||
# ── Agent selection ──
|
||
_agent_name = "mohe"
|
||
if "--agent" in sys.argv:
|
||
idx = sys.argv.index("--agent")
|
||
if idx + 1 < len(sys.argv):
|
||
_agent_name = sys.argv[idx + 1]
|
||
cfg = AGENTS.get(_agent_name, AGENTS["mohe"])
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# PID Lock — prevent duplicate instances
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
from proc_guard import guard as _proc_guard
|
||
_lock = _proc_guard(f"xmpp_bot_{_agent_name}")
|
||
if not _lock.ok:
|
||
print(_lock.message, flush=True)
|
||
sys.exit(1)
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Logging
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gateway", "logs")
|
||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||
_LOG_FILE = os.path.join(_LOG_DIR, f"xmpp_{_agent_name}.log")
|
||
_START_TIME = time.time()
|
||
|
||
|
||
def log(m: str):
|
||
with open(_LOG_FILE, "a", encoding="utf-8") as f:
|
||
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
|
||
|
||
|
||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# LLM Bridge Init — abstracted per agent
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_IS_CHAT_BRIDGE = cfg.get("bridge") == "chat_bridge"
|
||
_router = None # set only for chat_bridge (xxm)
|
||
|
||
# ── Kanban session support ──
|
||
_CALL_SEQ = 0
|
||
_KANBAN_SESSION_ID = cfg.get("kanban_session_id", None)
|
||
|
||
if _IS_CHAT_BRIDGE:
|
||
from chat_bridge import SessionBridge
|
||
from session_router import SessionRouter
|
||
_bridge = SessionBridge(session_id=cfg["session_id"])
|
||
_router = SessionRouter(bridge=_bridge, default_session=cfg["session_id"])
|
||
# Kanban-dedicated bridge + router for separate session
|
||
_kanban_sid = _KANBAN_SESSION_ID or f"{cfg['session_id']}-kanban"
|
||
_kanban_bridge = SessionBridge(session_id=_kanban_sid)
|
||
_kanban_router = SessionRouter(bridge=_kanban_bridge, default_session=_kanban_sid)
|
||
log(f"LLM: chat_bridge (session={cfg['session_id']})")
|
||
log(f"Kanban: chat_bridge (session={_kanban_sid})")
|
||
else:
|
||
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||
log(f"LLM: Hermes API ({cfg['gateway']})")
|
||
|
||
|
||
def _call_llm(content: str, sender: str, is_group: bool = False,
|
||
session_id: str | None = None) -> str:
|
||
"""Abstract LLM call. Returns raw response text (or empty string).
|
||
If session_id provided and differs from default, route to kanban handler."""
|
||
if _IS_CHAT_BRIDGE:
|
||
if session_id and session_id != cfg["session_id"]:
|
||
# Prepends kanban-handler instructions so LLM knows how to handle
|
||
kanban_content = (
|
||
"【看板处理协议】\n"
|
||
"收到卡片后三步判断:\n"
|
||
" A) 任务明确 → 直接执行 → 评论结果 + 更新状态\n"
|
||
" B) 信息不足 → 评论提问 + 设为 blocked\n"
|
||
" C) 不是我的活 → 评论说明 + 转派(如果能判断)\n"
|
||
"\n"
|
||
"汇报规则:\n"
|
||
" - 任务完成 → 简短 DM 给老莫摘要\n"
|
||
" - 评论/状态变更 → 不汇报\n"
|
||
" - 追问/转派 → 不汇报\n"
|
||
"\n"
|
||
"可用 API:\n"
|
||
" curl http://192.168.1.246:5803/api/kanban/t_xxx 查看卡片详情\n"
|
||
" 更新操作走 Kanban Dashboard UI\n"
|
||
"\n"
|
||
"卡片上下文不够?→ 用 session_search 查历史\n"
|
||
f"---\n{content}"
|
||
)
|
||
return _kanban_router.route("xmpp", sender, kanban_content) or ""
|
||
return _router.route("xmpp", sender, content) or ""
|
||
else:
|
||
return _call_hermes_api(content, session_id)
|
||
|
||
|
||
def _call_hermes_api(content: str, session_id: str | None = None) -> str:
|
||
"""POST to Hermes API, return response text or empty string."""
|
||
target_sid = session_id or cfg["session_id"]
|
||
try:
|
||
payload = json.dumps({
|
||
"model": "hermes-agent",
|
||
"messages": [{"role": "user", "content": content}]
|
||
}).encode()
|
||
req = urllib.request.Request(cfg["gateway"], data=payload, method="POST")
|
||
req.add_header("Content-Type", "application/json")
|
||
req.add_header("Authorization", "Bearer hermes123")
|
||
req.add_header("X-Hermes-Session-Id", target_sid)
|
||
result = _opener.open(req, timeout=600)
|
||
data = json.loads(result.read())
|
||
reply = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
return reply.strip()
|
||
except Exception as e:
|
||
log(f"!!! Hermes API error: {e}")
|
||
return ""
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# EasyTier control (Windows)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_EASYTIER_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||
"tools", "easytier")
|
||
_EASYTIER_CORE = os.path.join(_EASYTIER_DIR, "easytier-core.exe")
|
||
_EASYTIER_PID_FILE = os.path.join(_EASYTIER_DIR, "easytier.pid")
|
||
_EASYTIER_NET = "--network-name mynet --network-secret ce75d0a5"
|
||
_EASYTIER_RELAY = "--peers tcp://47.115.32.206:11010"
|
||
_EASYTIER_IP = "--ipv4 10.144.144.3"
|
||
_EASYTIER_FLAGS = "--disable-encryption" # TEMP: match relay+246; TODO remove after relay/246 also remove it
|
||
|
||
|
||
|
||
# ============================================================
|
||
# RDP Toggle - remote desktop via SSH reverse tunnel (port 8080)
|
||
# ============================================================
|
||
|
||
_RDP_SSH_HOST = 'root@47.115.32.206'
|
||
_RDP_SSH_PORT = '8080'
|
||
_RDP_PID_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'scripts', 'rdp_tunnel.pid')
|
||
|
||
def _rdp_tunnel_pid():
|
||
try:
|
||
if os.path.exists(_RDP_PID_FILE):
|
||
with open(_RDP_PID_FILE) as f:
|
||
return int(f.read().strip())
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _rdp_health_check():
|
||
"""Quick X.224 handshake test against localhost:3389. Returns True if RDP responds."""
|
||
import socket as _sk
|
||
try:
|
||
_s = _sk.create_connection(('127.0.0.1', 3389), timeout=5)
|
||
# X.224 Connection Request + RDP Negotiation Request (SSL|HYBRID|RDSTLS)
|
||
# TPKT len=0x13(19) 必须等于实际包长,否则服务器正确 RST(2026-07-22 误诊教训)
|
||
_s.sendall(bytes.fromhex('030000130ee00000000000010008000b000000'))
|
||
_s.settimeout(5)
|
||
_resp = _s.recv(1024)
|
||
_s.close()
|
||
if len(_resp) >= 6 and _resp[5] in (0xd0, 0x03):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _rdp_kill_huorong():
|
||
"""Try to temporarily disable Huorong network protection that may block RDP."""
|
||
import subprocess as _sp
|
||
try:
|
||
# Unload sysdiag filter driver if present
|
||
_sp.run(['fltmc', 'unload', 'sysdiag'], capture_output=True, timeout=5)
|
||
log('RDP: Huorong sysdiag filter unloaded')
|
||
except Exception:
|
||
pass
|
||
# Also try to stop Huorong services (may be protected, but worth a try)
|
||
try:
|
||
_sp.run(['taskkill', '/f', '/im', 'HipsDaemon.exe'], capture_output=True, timeout=5)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rdp_health_restore():
|
||
"""Full RDP health restore: restart TermService + add firewall rule + verify."""
|
||
import subprocess as _sp, time as _t
|
||
log('RDP: health check failed — attempting restore...')
|
||
# 1. Add explicit WFP allow rule for port 3389
|
||
try:
|
||
_sp.run(['netsh', 'advfirewall', 'firewall', 'add', 'rule',
|
||
'name=RDP-3389-TCP-AutoHeal', 'dir=in', 'protocol=tcp',
|
||
'localport=3389', 'action=allow', 'profile=any'],
|
||
capture_output=True, timeout=10)
|
||
log('RDP: added netsh allow rule for 3389')
|
||
except Exception:
|
||
pass
|
||
# 2. Try to unload Huorong filter
|
||
_rdp_kill_huorong()
|
||
# 3. Full TermService restart
|
||
_t.sleep(1)
|
||
try:
|
||
_sp.run(['net', 'stop', 'TermService', '/y'], capture_output=True, timeout=30)
|
||
except Exception:
|
||
_sp.run(['taskkill', '/f', '/im', 'svchost*'], capture_output=True, timeout=5)
|
||
_t.sleep(3)
|
||
try:
|
||
_sp.run(['net', 'start', 'TermService'], capture_output=True, timeout=30)
|
||
except Exception:
|
||
pass
|
||
_t.sleep(5)
|
||
# 4. Verify
|
||
ok = _rdp_health_check()
|
||
log(f'RDP: restore result: {"OK" if ok else "STILL FAILING"}')
|
||
return ok
|
||
|
||
def _rdp_enable():
|
||
"""Robust RDP enable — async step-machine. Writes live progress to
|
||
rdp_progress.json + structured lines to rdp_enable.log, returns immediately
|
||
with 'started'; dashboard polls /rdp action=progress for live steps."""
|
||
import threading as _th, json as _j
|
||
try:
|
||
if os.path.exists(_RDP_PROGRESS_FILE):
|
||
with open(_RDP_PROGRESS_FILE, encoding='utf-8') as f:
|
||
cur = _j.load(f)
|
||
if cur.get('state') == 'running':
|
||
return True, 'RDP enable 已在进行中(查看进度)'
|
||
except Exception:
|
||
pass
|
||
_rdp_write_progress('running', 'init', _RDP_STEP_MSG['init'], [])
|
||
_rdp_log('init', 'start', 'RDP enable requested')
|
||
_th.Thread(target=_rdp_enable_run, daemon=True).start()
|
||
return True, 'RDP enable 已启动(后台执行中,可看实时进度)'
|
||
|
||
def _rdp_enable_run():
|
||
"""The actual step-machine, runs in a daemon thread. Each step is recorded
|
||
to rdp_progress.json (live) and rdp_enable.log (structured, for later debug)."""
|
||
import subprocess as _sp, winreg as _wr, time as _t
|
||
steps = []
|
||
def mark(name, status, detail=''):
|
||
steps.append({'name': name, 'status': status, 'detail': detail, 'ts': _rdp_now()})
|
||
_rdp_write_progress('running', name, _RDP_STEP_MSG.get(name, name), steps, detail)
|
||
_rdp_log(name, status, detail)
|
||
|
||
try:
|
||
# Step 1: 开注册表(开RDP + 隧道场景统一关NLA——SSH已加密,NLA的CredSSP预认证
|
||
# 正是"卡正在配置远程电脑"的根源:连接到达TermService但NLA协商挂起、建不了会话)
|
||
try:
|
||
k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE)
|
||
_wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 0)
|
||
_wr.CloseKey(k)
|
||
mark('registry', 'ok', 'fDenyTSConnections=0')
|
||
except Exception as e:
|
||
mark('registry', 'fail', str(e))
|
||
try:
|
||
k2 = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp', 0, _wr.KEY_SET_VALUE)
|
||
_wr.SetValueEx(k2, 'UserAuthentication', 0, _wr.REG_DWORD, 0)
|
||
_wr.CloseKey(k2)
|
||
mark('registry', 'ok', 'NLA已关闭(UserAuthentication=0,隧道场景)')
|
||
except Exception as e:
|
||
mark('registry', 'warn', '关NLA失败: ' + str(e))
|
||
# 隧道/多GPU/DisplayLink 环境统一禁用硬件图形适配器(软件渲染):
|
||
# DisplayLink USB 显示扩展坞/NVIDIA 多GPU 与 RDP WDDM 图形初始化冲突,
|
||
# 是"卡正在配置远程电脑"(WDDM启用后挂起)的高频根因。软件渲染绕开它。
|
||
try:
|
||
import subprocess as _sp2
|
||
_sp2.run(['powershell', '-NoProfile', '-Command',
|
||
"New-Item -Path 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Terminal Services' -Force | Out-Null; Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Terminal Services' -Name 'bEnumerateHWBeforeSW' -Value 0 -Type DWord"],
|
||
capture_output=True, timeout=10)
|
||
mark('registry', 'ok', '已禁用硬件图形适配器(软件渲染,绕开DisplayLink/多GPU冲突)')
|
||
except Exception as e:
|
||
mark('registry', 'warn', '禁用硬件图形失败: ' + str(e))
|
||
|
||
# Step 2: 加入 RDP 用户组
|
||
try:
|
||
_sp.run(['net', 'localgroup', 'Remote Desktop Users', 'hmo', '/add'], capture_output=True, timeout=8)
|
||
mark('rdp_users', 'ok', 'hmo 已加入 Remote Desktop Users')
|
||
except Exception as e:
|
||
mark('rdp_users', 'warn', str(e))
|
||
|
||
# Step 3: 深度健康检查
|
||
hc = _rdp_deep_health_check()
|
||
mark('health_check', 'ok' if hc['healthy'] else 'warn',
|
||
'x224=%s svc=%s listen=%s 卡死=%d nla=%s cert=%s' % (
|
||
hc['x224'], hc['termservice'], hc['port_listen'], hc['stale_sessions'], hc['nla'], hc['cert_ok']))
|
||
|
||
# Step 4: 自愈(如需)
|
||
if not hc['healthy']:
|
||
_rdp_log('restore', 'start', 'issues: ' + '; '.join(hc['issues']))
|
||
cleared = _rdp_kill_stale_rdp_sessions()
|
||
if cleared:
|
||
_rdp_log('restore', 'detail', '清理卡死会话: ' + ','.join(cleared))
|
||
_rdp_health_restore()
|
||
hc2 = _rdp_deep_health_check()
|
||
# NLA 卡死处理:连接能到但建不了会话且 NLA 开 → 临时关 NLA 再重启(隧道内网场景可逆)
|
||
if not hc2['healthy'] and hc2.get('nla') == 1:
|
||
_rdp_log('restore', 'detail', '仍异常且NLA开启 → 临时关闭NLA并重启TermService')
|
||
_rdp_set_nla(False)
|
||
_t.sleep(1)
|
||
_sp.run(['net', 'stop', 'TermService', '/y'], capture_output=True, timeout=30)
|
||
_t.sleep(2)
|
||
_sp.run(['net', 'start', 'TermService'], capture_output=True, timeout=30)
|
||
_t.sleep(3)
|
||
mark('restore', 'ok', '自愈完成: ' + ('; '.join(hc['issues']) or 'no-op'))
|
||
else:
|
||
mark('restore', 'skip', '健康,无需自愈')
|
||
|
||
# Step 5: 建立隧道
|
||
_rdp_kill_tunnel()
|
||
_t.sleep(4) # 等阿里云端 8080 转发随旧 ssh 断开而释放,避免新隧道 forwarding failed
|
||
_existing = _rdp_find_tunnel_process()
|
||
if _existing:
|
||
_rdp_adopt_tunnel(_existing)
|
||
mark('tunnel', 'ok', '收养已有隧道 PID %d' % _existing)
|
||
else:
|
||
ok, msg = _rdp_start_tunnel()
|
||
mark('tunnel', 'ok' if ok else 'fail', msg)
|
||
if not ok:
|
||
_rdp_write_progress('failed', 'tunnel', '隧道建立失败', steps, msg)
|
||
_rdp_log('done', 'failed', msg)
|
||
return
|
||
|
||
# Step 6: 端到端验证
|
||
_t.sleep(2)
|
||
if _rdp_verify_endtoend():
|
||
mark('verify', 'ok', '端到端握手通过 (47.115.32.206:8080)')
|
||
_rdp_write_progress('done', 'done', '✓ RDP 已就绪,可连接 47.115.32.206:8080', steps, '')
|
||
_rdp_log('done', 'ok', 'RDP fully enabled and verified')
|
||
else:
|
||
mark('verify', 'warn', '端到端验证未通过(隧道通但RDP握手失败,可能需重试)')
|
||
_rdp_write_progress('done', 'done', '隧道已建立,端到端验证未通过,请稍后重试连接', steps, '')
|
||
_rdp_log('done', 'warn', 'tunnel up but end-to-end verify failed')
|
||
except Exception as e:
|
||
mark('done', 'fail', str(e))
|
||
_rdp_write_progress('failed', 'done', '启动失败: ' + str(e), steps, str(e))
|
||
_rdp_log('done', 'failed', str(e))
|
||
|
||
|
||
def _rdp_disable():
|
||
import subprocess as _sp, winreg as _wr
|
||
_rdp_kill_tunnel()
|
||
try:
|
||
k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE)
|
||
_wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 1)
|
||
_wr.CloseKey(k)
|
||
log('RDP access disabled')
|
||
return True, 'RDP access disabled'
|
||
except Exception as e:
|
||
return False, 'Failed to disable RDP: ' + str(e)
|
||
|
||
def _rdp_kill_tunnel():
|
||
import subprocess as _sp
|
||
pid = _rdp_tunnel_pid()
|
||
if pid:
|
||
try:
|
||
_sp.run(['taskkill', '/f', '/pid', str(pid)], capture_output=True, timeout=5)
|
||
log('Killed RDP tunnel (PID ' + str(pid) + ')')
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if os.path.exists(_RDP_PID_FILE):
|
||
os.remove(_RDP_PID_FILE)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rdp_find_tunnel_process():
|
||
"""Find a running ssh.exe hosting the -R 8080:localhost:3389 reverse tunnel.
|
||
Returns PID or None. Used to adopt tunnels started outside _rdp_enable
|
||
(e.g. by a crashed/restarted bot that left a detached ssh behind)."""
|
||
import subprocess as _sp
|
||
try:
|
||
# 纯单引号写法:避免 powershell -Command 下双引号转义失效
|
||
ps_cmd = ("Get-CimInstance Win32_Process | "
|
||
"Where-Object { $_.Name -eq 'ssh.exe' -and $_.CommandLine -like '*8080:localhost:3389*' } | "
|
||
"Select-Object -ExpandProperty ProcessId")
|
||
r = _sp.run(['powershell', '-NoProfile', '-Command', ps_cmd],
|
||
capture_output=True, text=True, timeout=15)
|
||
for line in r.stdout.splitlines():
|
||
line = line.strip()
|
||
if line.isdigit():
|
||
return int(line)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _rdp_adopt_tunnel(pid):
|
||
"""Write pid file for an externally-started tunnel so status tracking works."""
|
||
try:
|
||
with open(_RDP_PID_FILE, 'w') as f:
|
||
f.write(str(pid))
|
||
log(f'RDP: adopted existing tunnel (PID {pid})')
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _rdp_status():
|
||
import subprocess as _sp, winreg as _wr
|
||
tunnel_on = False
|
||
pid = _rdp_tunnel_pid()
|
||
if pid:
|
||
try:
|
||
r = _sp.run(['tasklist', '/fi', 'PID eq ' + str(pid), '/fi', 'imagename eq ssh.exe'], capture_output=True, text=True, timeout=5)
|
||
tunnel_on = 'ssh.exe' in r.stdout
|
||
except Exception:
|
||
pass
|
||
# pid file missing or process dead → look for an actual tunnel process and adopt it
|
||
if not tunnel_on:
|
||
found = _rdp_find_tunnel_process()
|
||
if found:
|
||
_rdp_adopt_tunnel(found)
|
||
tunnel_on = True
|
||
rdp_on = False
|
||
try:
|
||
k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_READ)
|
||
v, _ = _wr.QueryValueEx(k, 'fDenyTSConnections')
|
||
_wr.CloseKey(k)
|
||
rdp_on = (v == 0)
|
||
except Exception:
|
||
pass
|
||
return {'ok': True, 'tunnel_running': tunnel_on, 'rdp_enabled': rdp_on, 'rdp_port': 3389, 'tunnel_host': 'root@47.115.32.206', 'tunnel_port': 8080}
|
||
|
||
# ============================================================
|
||
# RDP Robustness — step-machine + progress + structured log
|
||
# 让 enable 按钮"点了就能用":深度检查 + 自愈 + 端到端验证 + 实时进度
|
||
# ============================================================
|
||
_RDP_PROGRESS_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'scripts', 'rdp_progress.json')
|
||
_RDP_ENABLE_LOG = os.path.join(_LOG_DIR, 'rdp_enable.log')
|
||
|
||
_RDP_STEP_MSG = {
|
||
'init': '开始启动 RDP 远程桌面',
|
||
'registry': '开启远程桌面注册表',
|
||
'rdp_users': '加入远程桌面用户组',
|
||
'health_check': '深度健康检查(TermService/端口/卡死连接/NLA/证书)',
|
||
'restore': '检测到异常,正在自我修复',
|
||
'tunnel': '建立 SSH 反向隧道',
|
||
'verify': '端到端连通性验证',
|
||
'done': '完成',
|
||
}
|
||
|
||
def _rdp_now():
|
||
import time as _t
|
||
return _t.strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
def _rdp_log(step, status, detail=''):
|
||
"""Append a structured line to rdp_enable.log AND mirror to bot log."""
|
||
line = ("[%s] [%s] %s %s" % (_rdp_now(), step.upper(), status.upper(), detail)).rstrip()
|
||
try:
|
||
with open(_RDP_ENABLE_LOG, 'a', encoding='utf-8') as f:
|
||
f.write(line + '\n')
|
||
except Exception:
|
||
pass
|
||
log('RDP ' + line)
|
||
|
||
def _rdp_write_progress(state, step, message, steps=None, detail=''):
|
||
"""Write rdp_progress.json for the dashboard to poll live steps."""
|
||
import json as _j
|
||
prog = {'state': state, 'step': step, 'message': message,
|
||
'detail': detail, 'steps': steps or [], 'updated': _rdp_now()}
|
||
try:
|
||
with open(_RDP_PROGRESS_FILE, 'w', encoding='utf-8') as f:
|
||
_j.dump(prog, f, ensure_ascii=False, indent=1)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rdp_progress():
|
||
"""Return current enable progress (for /rdp action=progress)."""
|
||
import json as _j
|
||
try:
|
||
if os.path.exists(_RDP_PROGRESS_FILE):
|
||
with open(_RDP_PROGRESS_FILE, encoding='utf-8') as f:
|
||
d = _j.load(f)
|
||
d['ok'] = True
|
||
return d
|
||
except Exception:
|
||
pass
|
||
return {'ok': True, 'state': 'idle', 'step': '', 'message': '无进行中的任务', 'steps': [], 'detail': ''}
|
||
|
||
def _rdp_read_enable_log(lines=80):
|
||
"""Tail the structured enable log (for /rdp action=enable_log)."""
|
||
try:
|
||
if os.path.exists(_RDP_ENABLE_LOG):
|
||
with open(_RDP_ENABLE_LOG, encoding='utf-8', errors='replace') as f:
|
||
data = f.readlines()
|
||
return {'ok': True, 'lines': [l.rstrip('\n') for l in data[-lines:]]}
|
||
except Exception as e:
|
||
return {'ok': False, 'error': str(e), 'lines': []}
|
||
return {'ok': True, 'lines': []}
|
||
|
||
def _rdp_deep_health_check():
|
||
"""Deep RDP health check — unlike _rdp_health_check (X.224/TCP only), this
|
||
also checks TermService, 3389 listen, stale/close-wait sessions, NLA setting
|
||
and cert validity, so it can detect "connects but stuck at configuring"
|
||
(NLA stall / dead session) that the shallow check blindly passes."""
|
||
import subprocess as _sp
|
||
res = {'x224': _rdp_health_check(), 'termservice': False, 'port_listen': False,
|
||
'stale_sessions': 0, 'nla': None, 'cert_ok': None, 'healthy': False, 'issues': []}
|
||
try:
|
||
r = _sp.run(['sc', 'query', 'TermService'], capture_output=True, text=True, timeout=8)
|
||
res['termservice'] = 'RUNNING' in r.stdout
|
||
except Exception:
|
||
pass
|
||
try:
|
||
r = _sp.run(['powershell', '-NoProfile', '-Command',
|
||
"if(Get-NetTCPConnection -LocalPort 3389 -State Listen -EA SilentlyContinue){'yes'}"],
|
||
capture_output=True, text=True, timeout=12)
|
||
res['port_listen'] = 'yes' in r.stdout
|
||
except Exception:
|
||
pass
|
||
try:
|
||
r = _sp.run(['powershell', '-NoProfile', '-Command',
|
||
"@(Get-NetTCPConnection -LocalPort 3389 -EA SilentlyContinue | Where-Object {$_.State -in 'CloseWait','FinWait1','FinWait2','LastAck','TimeWait'}).Count"],
|
||
capture_output=True, text=True, timeout=12)
|
||
n = r.stdout.strip()
|
||
res['stale_sessions'] = int(n) if n.isdigit() else 0
|
||
except Exception:
|
||
pass
|
||
try:
|
||
import winreg as _wr
|
||
k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp', 0, _wr.KEY_READ)
|
||
v, _ = _wr.QueryValueEx(k, 'UserAuthentication')
|
||
_wr.CloseKey(k)
|
||
res['nla'] = int(v)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
r = _sp.run(['powershell', '-NoProfile', '-Command',
|
||
"$c=Get-ChildItem 'Cert:\\LocalMachine\\Remote Desktop' -EA SilentlyContinue | Where-Object {$_.HasPrivateKey -and $_.NotAfter -gt (Get-Date)} | Select-Object -First 1; if($c){'ok'}else{'none'}"],
|
||
capture_output=True, text=True, timeout=12)
|
||
res['cert_ok'] = 'ok' in r.stdout
|
||
except Exception:
|
||
pass
|
||
issues = []
|
||
if not res['termservice']: issues.append('TermService未运行')
|
||
if not res['port_listen']: issues.append('3389未监听')
|
||
if not res['x224']: issues.append('X.224握手失败')
|
||
if res['stale_sessions'] > 0: issues.append('卡死连接x%d' % res['stale_sessions'])
|
||
if res['cert_ok'] is False: issues.append('RDP证书异常')
|
||
res['issues'] = issues
|
||
res['healthy'] = bool(res['x224'] and res['termservice'] and res['port_listen']
|
||
and res['stale_sessions'] == 0 and res['cert_ok'] is not False)
|
||
return res
|
||
|
||
def _rdp_kill_stale_rdp_sessions():
|
||
"""Log off half-dead/disconnected RDP sessions and clear close-wait conns that
|
||
block new session setup (a common cause of 'stuck at configuring')."""
|
||
import subprocess as _sp
|
||
cleared = []
|
||
try:
|
||
r = _sp.run(['qwinsta'], capture_output=True, text=True, timeout=8)
|
||
for line in r.stdout.splitlines():
|
||
if 'rdp-tcp#' in line and ('Disc' in line or '断开' in line or 'Down' in line):
|
||
parts = line.split()
|
||
for p in parts:
|
||
if p.isdigit() and int(p) > 0:
|
||
_sp.run(['logoff', p], capture_output=True, timeout=5)
|
||
cleared.append('session#' + p)
|
||
break
|
||
except Exception:
|
||
pass
|
||
return cleared
|
||
|
||
def _rdp_set_nla(enabled):
|
||
"""Set NLA (UserAuthentication) on RDP-Tcp. enabled=True→1, False→0."""
|
||
import winreg as _wr
|
||
try:
|
||
k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp', 0, _wr.KEY_SET_VALUE)
|
||
_wr.SetValueEx(k, 'UserAuthentication', 0, _wr.REG_DWORD, 1 if enabled else 0)
|
||
_wr.CloseKey(k)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _rdp_start_tunnel():
|
||
"""Start the SSH reverse tunnel (extracted from old _rdp_enable body).
|
||
Retries when the Aliyun-side 8080 forwarding is still held by a just-killed
|
||
previous tunnel ('remote port forwarding failed for listen port 8080') —
|
||
that port needs a few seconds to be released after the old ssh dies."""
|
||
import subprocess as _sp, time as _t, os
|
||
_retry_waits = [8, 12, 18, 25] # 递增等待:覆盖阿里云端 8080 转发随旧 ssh 断开而缓慢释放的时间
|
||
for _attempt in range(5):
|
||
try:
|
||
_ssh_key = None
|
||
for _user_home in [os.path.expanduser('~'), r'C:\Users\hmo']:
|
||
_candidate = os.path.join(_user_home, '.ssh', 'id_rsa')
|
||
if os.path.isfile(_candidate):
|
||
_ssh_key = _candidate
|
||
break
|
||
if not _ssh_key:
|
||
return False, 'SSH 私钥 ~/.ssh/id_rsa 未找到'
|
||
cmd = ['ssh.exe', '-i', _ssh_key,
|
||
'-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=30',
|
||
'-o', 'ExitOnForwardFailure=yes', '-o', 'IdentitiesOnly=yes',
|
||
'-N', '-R', '0.0.0.0:8080:localhost:3389', 'root@47.115.32.206']
|
||
si = _sp.STARTUPINFO()
|
||
si.dwFlags |= _sp.STARTF_USESHOWWINDOW
|
||
_ssh_stderr_path = os.path.join(_LOG_DIR, 'rdp_tunnel_ssh.log')
|
||
_ssh_err_fh = open(_ssh_stderr_path, 'a', encoding='utf-8')
|
||
_ssh_err_fh.write('\n' + '=' * 60 + '\n' + _t.strftime('%Y-%m-%d %H:%M:%S') + ' RDP tunnel start (attempt %d)\n' % (_attempt + 1))
|
||
_ssh_err_fh.flush()
|
||
_ssh_env = os.environ.copy()
|
||
_ssh_env['HOME'] = os.path.expanduser('~')
|
||
_ssh_env['USERPROFILE'] = os.path.expanduser('~')
|
||
p = _sp.Popen(cmd, startupinfo=si, stdout=_sp.DEVNULL, stderr=_ssh_err_fh,
|
||
env=_ssh_env,
|
||
creationflags=_sp.DETACHED_PROCESS | _sp.CREATE_NEW_PROCESS_GROUP)
|
||
_t.sleep(3)
|
||
if p.poll() is not None:
|
||
_ssh_err_fh.close()
|
||
# ssh 立即退出:判断是否 8080 被旧隧道残留占用(可重试)
|
||
_fwd_fail = False
|
||
try:
|
||
with open(_ssh_stderr_path, encoding='utf-8', errors='replace') as _ef:
|
||
_tail = _ef.read()[-600:]
|
||
_fwd_fail = ('forwarding failed' in _tail) or ('8080' in _tail and 'listen' in _tail)
|
||
except Exception:
|
||
pass
|
||
if _fwd_fail and _attempt < 4:
|
||
_wait = _retry_waits[_attempt]
|
||
_rdp_log('tunnel', 'warn', '8080 被旧隧道残留占用,等待 %ds 后重试 (%d/5)' % (_wait, _attempt + 1))
|
||
_rdp_write_progress('running', 'tunnel', 'SSH 反向隧道', None, '8080 残留占用,等待 %ds 重试 (%d/5)' % (_wait, _attempt + 1))
|
||
_t.sleep(_wait)
|
||
continue
|
||
return False, 'SSH 隧道立即退出(code %s),见 %s' % (p.poll(), _ssh_stderr_path)
|
||
with open(_RDP_PID_FILE, 'w') as f:
|
||
f.write(str(p.pid))
|
||
return True, '隧道已建立 PID %d' % p.pid
|
||
except Exception as e:
|
||
if _attempt < 4:
|
||
_t.sleep(_retry_waits[_attempt])
|
||
continue
|
||
return False, '隧道建立异常: ' + str(e)
|
||
return False, 'SSH 隧道多次失败(8080 可能被旧隧道残留占用,请稍后重试)'
|
||
|
||
def _rdp_verify_endtoend():
|
||
"""After tunnel is up, verify full chain via public endpoint 47.115.32.206:8080
|
||
with an X.224 handshake (proves 8080→tunnel→3389→RDP all work)."""
|
||
import socket as _sk
|
||
try:
|
||
_s = _sk.create_connection(('47.115.32.206', 8080), timeout=8)
|
||
_s.sendall(bytes.fromhex('030000130ee00000000000010008000b000000'))
|
||
_s.settimeout(8)
|
||
_resp = _s.recv(1024)
|
||
_s.close()
|
||
if len(_resp) >= 6 and _resp[5] in (0xd0, 0x03):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
# ── OpenCode Go Usage Monitor helpers ──
|
||
# Reads cached aggregation from gateway/temp/usage_stats.json.
|
||
# Triggers asynchronous collection by spawning usage_collector.py in a daemon thread.
|
||
# Cache file path + collector script path:
|
||
_USAGE_CACHE_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'temp', 'usage_stats.json')
|
||
_USAGE_COLLECTOR_SCRIPT = os.path.join(_GATEWAY_SCRIPTS, 'usage_collector.py')
|
||
_usage_collector_running = False # module-level lock flag
|
||
|
||
def _now_iso():
|
||
"""UTC ISO timestamp (avoids importing datetime everywhere we need this)."""
|
||
from datetime import datetime, timezone
|
||
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||
|
||
def _usage_read_cache():
|
||
"""Return cached usage stats from temp/usage_stats.json, or empty payload if missing."""
|
||
if not os.path.isfile(_USAGE_CACHE_FILE):
|
||
return {'ok': False, 'error': 'no cached data yet (collect never ran)',
|
||
'last_refresh_iso': None, 'accounts': []}
|
||
try:
|
||
with open(_USAGE_CACHE_FILE, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
# Don't pollute with no-stdout fields; just return the JSON object
|
||
return data
|
||
except Exception as e:
|
||
return {'ok': False, 'error': f'failed to read cache: {e}',
|
||
'last_refresh_iso': None, 'accounts': []}
|
||
|
||
def _usage_trigger_async():
|
||
"""
|
||
Spawn a daemon thread that runs usage_collector.py via subprocess.
|
||
Sets a module-level flag so concurrent calls don't overlap collections.
|
||
|
||
The collector runs in its own process so it cannot crash the bot.
|
||
Writes its result to gateway/temp/usage_stats.json (read by _usage_read_cache).
|
||
"""
|
||
global _usage_collector_running
|
||
if _usage_collector_running:
|
||
log('usage: collect_now already in-flight, skipping trigger')
|
||
return
|
||
_usage_collector_running = True
|
||
|
||
def _runner():
|
||
global _usage_collector_running
|
||
try:
|
||
import subprocess as _sp
|
||
log(f'usage: spawning usage_collector.py via {sys.executable}')
|
||
# Run collector as one-shot --print so its stdout is captured but not displayed
|
||
proc = _sp.run([sys.executable, _USAGE_COLLECTOR_SCRIPT],
|
||
capture_output=True, text=True, timeout=120)
|
||
log(f'usage: collector finished (rc={proc.returncode}, '
|
||
f'stdout_len={len(proc.stdout)}, stderr_len={len(proc.stderr)})')
|
||
if proc.returncode != 0 and proc.stderr:
|
||
log(f'usage_collector.py stderr:\n{proc.stderr[-1500:]}')
|
||
except Exception as e:
|
||
log(f'usage: collector thread crashed: {e}')
|
||
finally:
|
||
_usage_collector_running = False
|
||
|
||
t = threading.Thread(target=_runner, name='usage_collector_runner', daemon=True)
|
||
t.start()
|
||
log('usage: collect_now background thread spawned')
|
||
|
||
# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
|
||
_USAGE_AUTO_INTERVAL = 300 # 5 minutes
|
||
|
||
def _start_usage_auto_timer():
|
||
"""Start a daemon thread that auto-collects every 5 min (cookie keepalive + fresh data)."""
|
||
def _loop():
|
||
import time as _time
|
||
_time.sleep(10) # initial delay: let bot stabilize
|
||
while True:
|
||
try:
|
||
_usage_trigger_async()
|
||
except Exception as e:
|
||
log(f'usage: auto-collect error: {e}')
|
||
_time.sleep(_USAGE_AUTO_INTERVAL)
|
||
t = threading.Thread(target=_loop, name='usage_auto_timer', daemon=True)
|
||
t.start()
|
||
log(f'usage: auto-timer started (interval={_USAGE_AUTO_INTERVAL}s)')
|
||
|
||
def _start_easytier():
|
||
"""Start EasyTier on Windows."""
|
||
import subprocess as _sp
|
||
import time as _time
|
||
if not os.path.exists(_EASYTIER_CORE):
|
||
log(f"EasyTier binary not found: {_EASYTIER_CORE}")
|
||
return
|
||
# Kill ALL existing easytier-core.exe processes FIRST (prevents duplicate conflicts)
|
||
_sp.run(["taskkill", "/f", "/im", "easytier-core.exe"], capture_output=True, timeout=5)
|
||
_time.sleep(1)
|
||
# Clean stale PID file
|
||
try:
|
||
if os.path.exists(_EASYTIER_PID_FILE):
|
||
os.remove(_EASYTIER_PID_FILE)
|
||
except Exception:
|
||
pass
|
||
# Start
|
||
cmd = f'start /b "" "{_EASYTIER_CORE}" {_EASYTIER_NET} {_EASYTIER_RELAY} {_EASYTIER_IP} {_EASYTIER_FLAGS} --no-listener'
|
||
try:
|
||
_sp.run(cmd, shell=True, timeout=5)
|
||
log("EasyTier start command issued")
|
||
# Write PID file so future calls can track this instance
|
||
_time.sleep(2)
|
||
r = _sp.run(["tasklist", "/fi", "imagename eq easytier-core.exe", "/fo", "csv"],
|
||
capture_output=True, text=True, timeout=5)
|
||
import csv as _csv
|
||
for row in _csv.reader(r.stdout.splitlines()):
|
||
if len(row) >= 2 and row[0].strip('"') == "easytier-core.exe":
|
||
pid = int(row[1])
|
||
with open(_EASYTIER_PID_FILE, "w") as f:
|
||
f.write(str(pid))
|
||
log(f"EasyTier started (PID {pid})")
|
||
break
|
||
except Exception as e:
|
||
log(f"EasyTier start error: {e}")
|
||
|
||
|
||
def _stop_easytier():
|
||
"""Stop EasyTier on Windows."""
|
||
import subprocess as _sp
|
||
try:
|
||
_sp.run(["taskkill", "/f", "/im", "easytier-core.exe"], capture_output=True, timeout=5)
|
||
log("EasyTier stopped (taskkill)")
|
||
except Exception as e:
|
||
log(f"EasyTier stop error: {e}")
|
||
# Clean up PID file
|
||
try:
|
||
if os.path.exists(_EASYTIER_PID_FILE):
|
||
os.remove(_EASYTIER_PID_FILE)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _check_easytier() -> bool:
|
||
"""Check if EasyTier is running on Windows."""
|
||
import subprocess as _sp
|
||
try:
|
||
r = _sp.run(["tasklist", "/fi", "imagename eq easytier-core.exe"],
|
||
capture_output=True, text=True, timeout=5)
|
||
return "easytier-core.exe" in r.stdout
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Message Dedup
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_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
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Coordinator Protocol (shared across all agents)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_COORDINATOR: str = "mohe"
|
||
_GRANTED: str | None = None
|
||
_REVOKED_UNTIL: float = 0.0
|
||
_SHUTUP_PATTERNS = ["闭嘴", "别说话", "安静", "shut", "stfu", "别说了", "停"]
|
||
|
||
|
||
def _process_coordinator_signals(nickname: str, body: str) -> bool:
|
||
"""Parse coordinator/GRANT/REVOKE from incoming messages.
|
||
Returns True if message was a control signal (consumed, no further processing)."""
|
||
global _COORDINATOR, _GRANTED, _REVOKED_UNTIL
|
||
# 1. hmo switches coordinator
|
||
if nickname == 'hmo' and 'coordinator=' in body.lower():
|
||
for name in ('mohe', 'zhiwei', 'xxm'):
|
||
if f'coordinator={name}' in body.lower():
|
||
_COORDINATOR = name
|
||
_GRANTED = None
|
||
log(f"Coordinator switched to {name} by hmo")
|
||
return True
|
||
# 2. GRANT signal (overrides REVOKE)
|
||
gm = re.search(r'\[GRANT:(\w+)\]', body)
|
||
if gm:
|
||
_GRANTED = gm.group(1)
|
||
_REVOKED_UNTIL = 0
|
||
log(f"GRANT: {_GRANTED}")
|
||
return True
|
||
# 3. REVOKE signal (5min auto-restore)
|
||
rm = re.search(r'\[REVOKE:(\w+)\]', body)
|
||
if rm and rm.group(1) == cfg["nick"]:
|
||
_REVOKED_UNTIL = time.time() + 300
|
||
log(f"REVOKEd: {cfg['nick']} silenced for 5min")
|
||
return True
|
||
return False
|
||
|
||
|
||
def _check_shutup(body: str) -> bool:
|
||
"""hmo says shut up → 5min silence."""
|
||
lower = body.lower().strip()
|
||
for pat in _SHUTUP_PATTERNS:
|
||
if pat.lower() in lower:
|
||
_REVOKED_UNTIL = time.time() + 300
|
||
log(f"(shutup: '{pat}' → 5min silence)")
|
||
return True
|
||
return False
|
||
|
||
|
||
def _process_llm_grant(response: str):
|
||
"""Parse GRANT signal from LLM's own response."""
|
||
global _GRANTED
|
||
gm = re.search(r'\[GRANT:(\w+)\]', response)
|
||
if gm:
|
||
_GRANTED = gm.group(1)
|
||
_REVOKED_UNTIL = 0
|
||
log(f"LLM GRANT: {_GRANTED}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Response Extraction
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_SILENCE_PATTERNS = [
|
||
"保持沉默", "不应[该]?回复", "没有.*@.*我", "不是对[我我说]",
|
||
"跟我无关", "我不用回复", "不该回复", "不参与",
|
||
"不是我[应]?该[说回]",
|
||
]
|
||
_EXEC_RE = re.compile(r"##exec:(.+?)##", re.DOTALL)
|
||
_DELAY_RE = re.compile(r"##delay:?(\d+)?##")
|
||
_DELAY_DEFAULT = 15
|
||
_EXEC_TIMEOUT = 60
|
||
|
||
|
||
def _strip_toolcall_xml(text: str) -> str:
|
||
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 blocks, natural language silence.
|
||
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
|
||
if not t.startswith("__SILENT__"):
|
||
first = t.split("\n", 1)[0]
|
||
for pat in _SILENCE_PATTERNS:
|
||
if re.search(pat, first):
|
||
return None
|
||
return t
|
||
# Has __SILENT__ prefix
|
||
parts = t.split("\n", 1)
|
||
if len(parts) < 2:
|
||
return None
|
||
rest = parts[1].strip()
|
||
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
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Message Batching (3s debounce + serialized processing)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_BATCH_WINDOW = 3.0
|
||
_BATCH_TIMEOUT = 300
|
||
_batch_entries: dict[str, list[str]] = {}
|
||
_batch_timers: dict[str, threading.Timer] = {}
|
||
_batch_processing: set[str] = set()
|
||
_batch_pending: dict[str, list[str]] = {}
|
||
_batch_lock = threading.Lock()
|
||
_BOT_NICK = cfg["nick"]
|
||
|
||
|
||
def _run_command(cmd: str) -> str:
|
||
"""Execute ##exec:command## shell command."""
|
||
log(f"(exec: {cmd[:120]})")
|
||
try:
|
||
import subprocess
|
||
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() or f"(no output, exit={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})"
|
||
|
||
|
||
def _schedule_delayed(delay_sec: int, room: str):
|
||
"""Schedule ##delay:N## re-invocation."""
|
||
global _xmpp_ref
|
||
import subprocess as _sp
|
||
|
||
def _fire():
|
||
bot = _xmpp_ref
|
||
if not bot:
|
||
return
|
||
try:
|
||
prompt = "时间到,请根据最新的信息汇报结果。"
|
||
raw = _call_llm(prompt, room, is_group=True)
|
||
reply = _extract_response(raw)
|
||
if reply:
|
||
text = reply.strip()
|
||
bot.send_message(mto=room, mbody=text, mtype='groupchat')
|
||
log(f"-> [Delay][{room}]: {text[:80]}")
|
||
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})")
|
||
|
||
|
||
def _batch_done(room: str):
|
||
"""Called when batch LLM 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)")
|
||
|
||
|
||
def _fire_batch(room: str):
|
||
"""Collect batched entries and call LLM."""
|
||
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():
|
||
timed_out = [False]
|
||
|
||
def _timeout():
|
||
timed_out[0] = True
|
||
log(f"[Batch][{room}] TIMEOUT ({_BATCH_TIMEOUT}s)")
|
||
_batch_done(room)
|
||
|
||
timer = threading.Timer(_BATCH_TIMEOUT, _timeout)
|
||
timer.daemon = True
|
||
timer.start()
|
||
try:
|
||
raw = _call_llm(combined, room, is_group=True)
|
||
if not timed_out[0]:
|
||
timer.cancel()
|
||
_process_llm_reply(raw, room)
|
||
else:
|
||
log(f"[Batch][{room}] route returned after timeout, discarded")
|
||
except Exception as e:
|
||
log(f"!!! BATCH: {e}")
|
||
if not timed_out[0]:
|
||
timer.cancel()
|
||
_batch_done(room)
|
||
|
||
threading.Thread(target=_handle, daemon=True).start()
|
||
|
||
|
||
def _batch_group_message(room: str, nickname: str, body: str) -> bool:
|
||
"""Add message to room batch. Returns True if batched, False if @mention (immediate)."""
|
||
if f"@{_BOT_NICK}" in body or body.startswith(_BOT_NICK):
|
||
return False
|
||
formatted = f"[{nickname}]: {body}"
|
||
with _batch_lock:
|
||
if room in _batch_processing:
|
||
_batch_pending.setdefault(room, []).append(formatted)
|
||
return True
|
||
timer = _batch_timers.pop(room, None)
|
||
if timer:
|
||
timer.cancel()
|
||
_batch_entries.setdefault(room, []).append(formatted)
|
||
t = threading.Timer(_BATCH_WINDOW, _fire_batch, args=[room])
|
||
t.daemon = True
|
||
t.start()
|
||
_batch_timers[room] = t
|
||
return True
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# MAM Recovery — fetch recent history on startup
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_MAM_RECOVERY = True
|
||
_MAM_RECOVERY_LOCK = threading.Lock()
|
||
_MAM_MARK_DONE = False
|
||
_MAM_TIMEOUT = 30
|
||
|
||
|
||
def _set_mam_done():
|
||
global _MAM_RECOVERY
|
||
with _MAM_RECOVERY_LOCK:
|
||
_MAM_RECOVERY = False
|
||
|
||
|
||
def _is_mam_recovery() -> bool:
|
||
if time.time() - _START_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
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# XML Escape
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _escape(text: str) -> str:
|
||
return (text.replace("&", "&").replace("<", "<")
|
||
.replace(">", ">").replace('"', """))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# HTTP Bridge — health, presence, messages, POST send
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
_HTTP_PORT = cfg["http_port"]
|
||
_MSG_BUF: list[dict] = []
|
||
_MSG_BUF_LOCK = threading.Lock()
|
||
_xmpp_ref = None # set after bot creation
|
||
|
||
|
||
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:]
|
||
|
||
|
||
|
||
_BRIDGE_API_KEY = cfg.get('bridge_api_key', '')
|
||
|
||
def _bridge_auth(self) -> bool:
|
||
if not _BRIDGE_API_KEY:
|
||
return True
|
||
hdr = self.headers.get('X-Api-Key', '')
|
||
if hdr == _BRIDGE_API_KEY:
|
||
return True
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
qs = urllib.parse.parse_qs(parsed.query)
|
||
if qs.get('key', [None])[0] == _BRIDGE_API_KEY:
|
||
return True
|
||
return False
|
||
|
||
class _BridgeHandler(http.server.BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
# /health 免认证,供监控系统使用
|
||
if parsed.path == "/health":
|
||
try:
|
||
bot = _xmpp_ref
|
||
session_ok = bot.session_started_event.is_set() if (bot and hasattr(bot, 'session_started_event')) else False
|
||
socket_ok = bot.is_connected() if (bot and hasattr(bot, 'is_connected')) else False
|
||
self._reply(200, {
|
||
"ok": True, "xmpp_connected": session_ok or socket_ok,
|
||
"agent": _agent_name, "jid": cfg["jid"],
|
||
"uptime_sec": int(time.time() - _START_TIME),
|
||
"muc_rooms": cfg["muc_rooms"],
|
||
})
|
||
except Exception as e:
|
||
self._reply(500, {"ok": False, "error": str(e)})
|
||
return
|
||
if not _bridge_auth(self):
|
||
self._reply(401, {'ok': False, 'error': 'unauthorized'})
|
||
return
|
||
if parsed.path == "/muc":
|
||
try:
|
||
muc_info = {"rooms": {}}
|
||
bot = _xmpp_ref
|
||
if bot is not None and 'xep_0045' in bot.plugin:
|
||
muc_plugin = bot.plugin['xep_0045']
|
||
for room_jid in cfg["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():
|
||
room_data["participants"].append({
|
||
"nick": nick,
|
||
"jid": str(info.get('jid', '')),
|
||
"affiliation": str(info.get('affiliation', '')),
|
||
"role": str(info.get('role', '')),
|
||
})
|
||
except Exception as e:
|
||
room_data["error"] = str(e)
|
||
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.startswith("/presence"):
|
||
jid_to_check = parsed.path[len("/presence/"):].strip()
|
||
if not jid_to_check:
|
||
self._reply(400, {"ok": False, "error": "missing JID"})
|
||
return
|
||
try:
|
||
info = {"jid": jid_to_check, "online": False, "resources": []}
|
||
bot = _xmpp_ref
|
||
if bot and hasattr(bot, 'client_roster'):
|
||
roster = bot.client_roster
|
||
if jid_to_check in roster:
|
||
resources = list(roster[jid_to_check].resources.keys())
|
||
info["online"] = len(resources) > 0
|
||
info["resources"] = resources
|
||
self._reply(200, info)
|
||
except Exception as e:
|
||
self._reply(500, {"ok": False, "error": str(e)})
|
||
return
|
||
if parsed.path == "/messages":
|
||
try:
|
||
qs = urllib.parse.parse_qs(parsed.query)
|
||
sender = qs.get("from", [None])[0]
|
||
with _MSG_BUF_LOCK:
|
||
msgs = list(_MSG_BUF)
|
||
if sender:
|
||
msgs = [m for m in msgs if m["from"] == sender]
|
||
self._reply(200, {"ok": True, "count": len(msgs), "messages": msgs[-50:]})
|
||
except Exception as e:
|
||
self._reply(500, {"ok": False, "error": str(e)})
|
||
return
|
||
self._reply(404, {"ok": False, "error": "not found"})
|
||
|
||
def do_POST(self):
|
||
if not _bridge_auth(self):
|
||
self._reply(401, {'ok': False, 'error': 'unauthorized'})
|
||
return
|
||
try:
|
||
length = int(self.headers.get('Content-Length', 0))
|
||
body = json.loads(self.rfile.read(length))
|
||
path = urllib.parse.urlparse(self.path).path.rstrip('/')
|
||
|
||
|
||
if path == '/rdp':
|
||
action = body.get('action', '')
|
||
if action == 'start':
|
||
ok, msg = _rdp_enable()
|
||
self._reply(200, {'ok': ok, 'message': msg})
|
||
elif action == 'stop':
|
||
ok, msg = _rdp_disable()
|
||
self._reply(200, {'ok': ok, 'message': msg})
|
||
elif action == 'status':
|
||
self._reply(200, _rdp_status())
|
||
elif action == 'progress':
|
||
self._reply(200, _rdp_progress())
|
||
elif action == 'enable_log':
|
||
self._reply(200, _rdp_read_enable_log(body.get('lines', 80)))
|
||
else:
|
||
self._reply(400, {'ok': False, 'error': 'action must be start|stop|status|progress|enable_log'})
|
||
return
|
||
# /usage endpoint — OpenCode Go usage monitor (read cache / trigger collection)
|
||
# Pattern mirrors /rdp and /easytier. Two actions:
|
||
# - status: return cached gateway/temp/usage_stats.json (instant, ~1ms)
|
||
# - collect_now: spawn background thread running usage_collector.py (non-blocking,
|
||
# refresh takes ~10-15s); client should poll status a few seconds later
|
||
if path == "/usage":
|
||
action = body.get('action', 'status')
|
||
if action == 'status':
|
||
self._reply(200, _usage_read_cache())
|
||
elif action == 'collect_now':
|
||
_usage_trigger_async()
|
||
self._reply(200, {'ok': True,
|
||
'message': 'collection triggered (takes ~10-15s); poll GET /usage action=status shortly',
|
||
'triggered_at': _now_iso()})
|
||
else:
|
||
self._reply(400, {'ok': False, 'error': 'action must be status|collect_now'})
|
||
return
|
||
# /easytier endpoint — execute EasyTier action locally (no XMPP DM)
|
||
if path == "/easytier":
|
||
action = body.get("action", "")
|
||
if action == "start":
|
||
_start_easytier()
|
||
self._reply(200, {"ok": True, "message": "EasyTier started"})
|
||
elif action == "stop":
|
||
_stop_easytier()
|
||
self._reply(200, {"ok": True, "message": "EasyTier stopped"})
|
||
elif action == "status":
|
||
running = _check_easytier()
|
||
self._reply(200, {"ok": True, "running": running})
|
||
else:
|
||
self._reply(400, {"ok": False, "error": "action must be start|stop|status"})
|
||
return
|
||
# /send — 发送消息到 MUC 群聊
|
||
if path == "/send":
|
||
to = body.get('to', '')
|
||
if not to:
|
||
rooms = cfg.get("muc_rooms", [])
|
||
to = rooms[0] if rooms else "coregroup@conference.yoin.fun"
|
||
msg = body.get('message', '') or body.get('body', '')
|
||
msg_type = body.get('type', 'groupchat')
|
||
if not msg:
|
||
self._reply(400, {"ok": False, "error": "empty message"})
|
||
return
|
||
bot = _xmpp_ref
|
||
if bot:
|
||
bot.send_message(mto=to, mbody=msg.strip(), mtype=msg_type)
|
||
_record_group_msg(cfg["nick"], msg)
|
||
log(f"[http] → [{to.split('@')[0]}]: {msg[:80]} (type={msg_type})")
|
||
self._reply(200, {"ok": True})
|
||
return
|
||
|
||
to = body.get('to', cfg["muc_rooms"][0])
|
||
msg = body.get('message', '') or body.get('body', '')
|
||
msg_type = body.get('type', 'groupchat')
|
||
if not msg:
|
||
self._reply(400, {"ok": False, "error": "empty message"})
|
||
return
|
||
safe = _escape(msg.strip())
|
||
bot = _xmpp_ref
|
||
if bot:
|
||
bot.send_message(mto=to, mbody=msg.strip(), mtype=msg_type)
|
||
_record_group_msg(cfg["nick"], msg)
|
||
log(f"[http] → [{to.split('@')[0]}]: {msg[:80]} (type={msg_type})")
|
||
self._reply(200, {"ok": True})
|
||
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
|
||
|
||
|
||
def _start_http_bridge():
|
||
_httpd = http.server.HTTPServer(('0.0.0.0', _HTTP_PORT), _BridgeHandler)
|
||
_t = threading.Thread(target=_httpd.serve_forever, daemon=True)
|
||
_t.start()
|
||
log(f"HTTP bridge ready on :{_HTTP_PORT}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Reply Processing (shared for all LLM responses)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _process_llm_reply(raw_reply: str, room: str):
|
||
"""Process LLM response: check silence/delay/exec/send."""
|
||
global _xmpp_ref
|
||
if not raw_reply:
|
||
_batch_done(room)
|
||
return
|
||
# Parse GRANT signal from LLM response
|
||
_process_llm_grant(raw_reply)
|
||
# ##delay:N## → schedule later
|
||
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
|
||
# ##exec:command## → run command, use output as reply
|
||
exec_m = _EXEC_RE.search(raw_reply)
|
||
if exec_m:
|
||
output = _run_command(exec_m.group(1))
|
||
raw_reply = _EXEC_RE.sub(output, raw_reply, count=1)
|
||
# Extract actual response
|
||
reply_text = _extract_response(raw_reply)
|
||
if reply_text:
|
||
text = reply_text.strip()
|
||
bot = _xmpp_ref
|
||
if bot:
|
||
bot.send_message(mto=room, mbody=text, mtype='groupchat')
|
||
log(f"-> [{room.split('@')[0]}]: {text[:80]}")
|
||
else:
|
||
log(f"-> [{room.split('@')[0]}]: (silent)")
|
||
_batch_done(room)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Group message handler
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _handle_group_message(msg):
|
||
"""Process a groupchat message (runs in thread)."""
|
||
global _COORDINATOR, _GRANTED, _REVOKED_UNTIL
|
||
if _is_mam_recovery():
|
||
return
|
||
msg_id = msg.get("id", "")
|
||
if _is_duplicate(msg_id):
|
||
return
|
||
body = str(msg["body"]).strip()
|
||
if not body:
|
||
return
|
||
full_from = str(msg["from"])
|
||
room = full_from.split("/")[0]
|
||
nickname = full_from.split("/")[1] if "/" in full_from else ""
|
||
# Self-message skip
|
||
if nickname == cfg["nick"]:
|
||
log(f"(self) {body[:80]}")
|
||
return
|
||
_record_group_msg(nickname, body)
|
||
# Coordinator signals
|
||
if _process_coordinator_signals(nickname, body):
|
||
return
|
||
# Revoke check
|
||
is_revoked = time.time() < _REVOKED_UNTIL
|
||
if is_revoked and _GRANTED == cfg["nick"]:
|
||
_GRANTED = None
|
||
is_revoked = False
|
||
log(f"GRANT overrides REVOKE for {cfg['nick']}")
|
||
if _check_shutup(body):
|
||
return
|
||
if is_revoked:
|
||
body = f"【只读消息】你被收回发言权。只需了解内容。输出 __SILENT__。\n\n[核心群 {room}] {nickname} 说: {body}"
|
||
# Batch or immediate (@mention)
|
||
if _batch_group_message(room, nickname, body):
|
||
log(f"[{room.split('@')[0]}] {nickname}: {body[:80]} (batched)")
|
||
return
|
||
log(f"[{room.split('@')[0]}] {nickname}: {body[:80]}")
|
||
raw = _call_llm(body, full_from, is_group=True)
|
||
_process_llm_reply(raw, room)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Private message handler
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _handle_private_message(msg):
|
||
"""Process a private chat message."""
|
||
global _CALL_SEQ
|
||
if msg["type"] == "groupchat":
|
||
return
|
||
msg_id = msg.get("id", "")
|
||
if _is_duplicate(msg_id):
|
||
return
|
||
body = str(msg["body"]).strip()
|
||
sender = str(msg["from"]).split("/")[0]
|
||
log(f"<{sender}> {body[:80]}")
|
||
if sender == cfg["jid"]:
|
||
log("(skipped self)")
|
||
return
|
||
if time.time() < _REVOKED_UNTIL:
|
||
log(f"(silenced) <{sender}> dropped")
|
||
return
|
||
if _check_shutup(body):
|
||
return
|
||
# ── Kanban routing ──
|
||
_CALL_SEQ += 1
|
||
is_kanban = body.startswith('[Kanban]')
|
||
target_sid = _KANBAN_SESSION_ID if is_kanban else cfg["session_id"]
|
||
if is_kanban:
|
||
log(f"📋 看板通知(#{_CALL_SEQ}): {body[:80]}")
|
||
# ── EasyTier toggle ──
|
||
if body.startswith('[EasyTier]'):
|
||
action = body.replace('[EasyTier]', '').strip().lower()
|
||
log(f"🔌 EasyTier command: {action}")
|
||
if action == 'start':
|
||
_start_easytier()
|
||
reply_text = "[EasyTier] started on Windows"
|
||
elif action == 'stop':
|
||
_stop_easytier()
|
||
reply_text = "[EasyTier] stopped on Windows"
|
||
else:
|
||
reply_text = f"[EasyTier] unknown action: {action}"
|
||
bot = _xmpp_ref
|
||
if bot:
|
||
bot.send_message(mto=sender, mbody=reply_text, mtype='chat')
|
||
log(f"-> {sender}: {reply_text}")
|
||
return
|
||
raw = _call_llm(body, sender, is_group=False, session_id=target_sid)
|
||
if raw:
|
||
reply = _extract_response(raw)
|
||
if reply:
|
||
text = reply.strip()
|
||
bot = _xmpp_ref
|
||
if bot:
|
||
bot.send_message(mto=sender, mbody=text, mtype='chat')
|
||
log(f"-> {sender}: {text[:80]}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# AgentBot Class
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
import slixmpp
|
||
|
||
|
||
class AgentBot(slixmpp.ClientXMPP):
|
||
def __init__(self):
|
||
super().__init__(cfg["jid"], cfg["password"])
|
||
# Connection settings
|
||
self.enable_direct_tls = False
|
||
self.enable_starttls = True
|
||
self.auto_reconnect = True
|
||
self.reconnect_max_delay = 10
|
||
self.whitespace_keepalive = True
|
||
self.whitespace_keepalive_interval = 30
|
||
# SSL: accept self-signed certs
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
self.ssl_context = ctx
|
||
# Event handlers
|
||
self.add_event_handler("session_start", self._on_session_start)
|
||
self.add_event_handler("message", self._on_any_message)
|
||
self.add_event_handler("groupchat_message", self._on_group_msg)
|
||
self.add_event_handler("disconnected", self._on_disconnected)
|
||
self.add_event_handler("connected", self._on_connected)
|
||
self.add_event_handler("session_end", self._on_session_end)
|
||
self.add_event_handler("connection_failed", self._on_conn_failed)
|
||
# MUC plugin
|
||
self.register_plugin('xep_0045')
|
||
|
||
def _on_connected(self, event):
|
||
log("connection established")
|
||
|
||
def _on_session_start(self, event):
|
||
self.send_presence()
|
||
self.get_roster()
|
||
log(f"{cfg['jid']} online")
|
||
# Register MAM plugin lazily
|
||
try:
|
||
self.register_plugin('xep_0313')
|
||
except Exception:
|
||
log("(MAM: xep_0313 not available)")
|
||
# Join MUC rooms
|
||
async def _join_all():
|
||
for room_jid in cfg["muc_rooms"]:
|
||
try:
|
||
self.plugin['xep_0045'].join_muc(room_jid, cfg["nick"])
|
||
presence = (
|
||
f"<presence to='{room_jid}/{cfg['nick']}'>"
|
||
f"<x xmlns='http://jabber.org/protocol/muc'>"
|
||
f"<history maxstanzas='0'/>"
|
||
f"</x></presence>"
|
||
)
|
||
self.send_raw(presence)
|
||
log(f"Joined {room_jid}")
|
||
except Exception as e:
|
||
log(f"MUC join failed {room_jid}: {e}")
|
||
await asyncio.sleep(2)
|
||
await asyncio.sleep(3)
|
||
await self._fetch_mam_history()
|
||
asyncio.ensure_future(_join_all())
|
||
|
||
async def _fetch_mam_history(self):
|
||
"""Query MAM for recent MUC messages to rebuild context."""
|
||
if 'xep_0313' not in self.plugin:
|
||
log("(MAM: no plugin)")
|
||
_set_mam_done()
|
||
return
|
||
# MAM recovery used in _on_session_start
|
||
try:
|
||
for room_jid in cfg["muc_rooms"]:
|
||
log(f"(MAM: querying {room_jid} for last 50 messages...)")
|
||
results = await self.plugin['xep_0313'].retrieve(
|
||
jid=room_jid, rsm={'max': 50},
|
||
)
|
||
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 '?'
|
||
# Feed into context for chat_bridge (xxm)
|
||
if _IS_CHAT_BRIDGE:
|
||
role = 'user' if nick != cfg["nick"] else 'assistant'
|
||
try:
|
||
_bridge._append_to_log(role, f"[{nick}]: {body[:300]}")
|
||
except Exception:
|
||
pass
|
||
count += 1
|
||
log(f"(MAM: loaded {count} msgs from {room_jid})")
|
||
_set_mam_done()
|
||
log("(MAM recovery complete)")
|
||
except Exception as e:
|
||
log(f"(MAM error: {e})")
|
||
_set_mam_done()
|
||
|
||
def _on_group_msg(self, msg):
|
||
threading.Thread(target=_handle_group_message, args=[msg], daemon=True).start()
|
||
|
||
def _on_any_message(self, msg):
|
||
threading.Thread(target=_handle_private_message, args=[msg], daemon=True).start()
|
||
|
||
def _on_session_end(self, event):
|
||
log(f"session ended")
|
||
|
||
def _on_conn_failed(self, event):
|
||
log(f"connection failed: {event}")
|
||
|
||
def _on_disconnected(self, event):
|
||
log(f"disconnected, reconnecting...")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Main
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def main():
|
||
log(f"Starting {cfg['jid']} ({cfg['name_cn']}) — agent={_agent_name}")
|
||
if _IS_CHAT_BRIDGE:
|
||
log(f" LLM: chat_bridge (session={cfg['session_id']})")
|
||
else:
|
||
log(f" LLM: Hermes API ({cfg['gateway']})")
|
||
log(f" Server: {cfg['server']}:{cfg['port']}")
|
||
log(f" Rooms: {cfg['muc_rooms']}")
|
||
|
||
bot = AgentBot()
|
||
global _xmpp_ref
|
||
_xmpp_ref = bot
|
||
|
||
_start_http_bridge()
|
||
_start_usage_auto_timer()
|
||
|
||
bot.connect(host=cfg["server"], port=cfg["port"])
|
||
log(f"Connecting {cfg['jid']}@{cfg['server']}:{cfg['port']}")
|
||
|
||
loop = asyncio.get_event_loop()
|
||
|
||
async def _status_check():
|
||
skipped = 0
|
||
while True:
|
||
await asyncio.sleep(60)
|
||
alive = bot and bot.is_connected() if hasattr(bot, 'is_connected') else False
|
||
if not alive:
|
||
skipped += 1
|
||
log(f"[R02] XMPP 连接已断开 ({skipped}/3),等待重连...")
|
||
if skipped >= 3:
|
||
log("[R02] 连续 3 次检测到连接断开,退出进程让 systemd 重启")
|
||
os._exit(1)
|
||
else:
|
||
if skipped > 0:
|
||
log(f"(连接已恢复,跳过 {skipped} 次检测)")
|
||
skipped = 0
|
||
|
||
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
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|