first commit

This commit is contained in:
Prometheus-first
2026-10-15 18:27:46 +08:00
commit 68eb19ea35
88 changed files with 10319 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
"""长期运行、连接监督与本地控制台命令。"""
from __future__ import annotations
import json
import logging
import queue
import shlex
import sys
import threading
import time
from .backend import CAPABILITIES, TEXT_CAPABILITIES, NativeFileHelperBackend, NativeTextBackend
from .plugins import PluginManager
from .sdk import ServiceFacade
from .members import MemberStore
from .personality import prepare_switch
logger = logging.getLogger("Bot")
class BotApplication:
def __init__(self, root, store, *, backend_factory=None):
self.root, self.store = root, store
self.config = store.load()
self.service = ServiceFacade(text_delay=self.config.get("text_send_delay"))
self.members = MemberStore.from_config(self.config, self.store.path.parent)
generic = self.config.get("backend") == "native_text"
capabilities = TEXT_CAPABILITIES if generic else CAPABILITIES
self.plugins = PluginManager(root / "plugins", store, self.service, capabilities,
self.config.get("plugin_queue_size", 128), member_store=self.members)
backend_type = NativeTextBackend if generic else NativeFileHelperBackend
self.backend_factory = backend_factory or (lambda: backend_type(root, self.config))
self.backend = None
self.stop_event = threading.Event()
self.commands = queue.Queue()
self.last_cleanup = None
self._connection_ready = False
self._last_connection_log = 0
self._last_connection_error = ""
def _log_connection_error(self, error):
text = f"{type(error).__name__}: {error}"
now = time.monotonic()
if text != self._last_connection_error or now - self._last_connection_log >= 30:
logger.warning("等待微信连接:%s", text)
self._last_connection_error, self._last_connection_log = text, now
def _connect(self):
try:
candidate = self.backend_factory()
self.backend = candidate
self.service.bind(candidate)
candidate.start(self._emit)
self._connection_ready = True
self._last_connection_error = ""
logger.info("微信已连接,PID=%s;收发范围:%s", candidate.pid,
getattr(candidate, "scope", "文本"))
except Exception as error:
self._connection_ready = False
self._log_connection_error(error)
self._disconnect()
def _emit(self, message):
allowed = self.config.get("chat_allowlist", [])
if not allowed or message.chat_id in allowed:
if self.members is not None:
try:
self.members.record(message)
except Exception:
logger.exception("群文本保存失败,消息继续分发到插件")
if message.data.get("dispatch_to_plugins", True):
if self.plugins.is_active("personality"):
message = prepare_switch(message, self.members)
self.plugins.dispatch(message)
def _disconnect(self):
self._connection_ready = False
try:
self.last_cleanup = self.service.close()
self.backend = None
return True
except Exception as error:
# 清理忙时保留实例,只重试清理,不覆盖 DLL、不再次注入。
self._log_connection_error(error)
return False
def execute(self, command):
words = shlex.split(command)
if not words:
return
action = words[0].lower()
if action in ("quit", "exit", "stop") and len(words) == 1:
self.stop_event.set()
elif action == "help" and len(words) == 1:
logger.info("命令:status / chats / members [群ID] / history 称呼 [条数] / plugins / enable 名称 / disable 名称 / reload [名称] / quit")
elif action == "status" and len(words) == 1:
logger.info("状态:%s", json.dumps({"connected": self._connection_ready,
"pid": self.backend.pid if self.backend else None,
"native": self.backend.diagnostics() if self.backend and hasattr(self.backend, "diagnostics") else {},
"plugins": self.plugins.status()}, ensure_ascii=False))
elif action == "chats" and len(words) == 1:
rows = self.backend.chats() if self.backend and hasattr(self.backend, "chats") else []
logger.info("本次连接已接收的会话:%s", json.dumps(rows, ensure_ascii=False))
elif action == "members" and len(words) in (1, 2):
if self.members is None:
raise ValueError("尚未配置成员目录")
rows = [{"name": m.name, "display_name": m.display_name, "wxid": m.wxid,
"room": m.room, "bound": bool(m.wxid)} for m in self.members.list_members(words[1] if len(words) == 2 else None)]
logger.info("成员目录:%s", json.dumps(rows, ensure_ascii=False))
elif action == "history" and len(words) in (2, 3):
if self.members is None:
raise ValueError("尚未配置成员目录")
rows = self.members.history(words[1], limit=int(words[2]) if len(words) == 3 else 10)
logger.info("成员聊天记录:%s", json.dumps(rows, ensure_ascii=False))
elif action == "plugins" and len(words) == 1:
for row in self.plugins.status():
state = "运行" if row["active"] else "停用"
logger.info("%s [%s] 已处理=%s 异常=%s 丢弃=%s %s", row["name"], state,
row["handled"], row["errors"], row["dropped"], row["error"])
elif action in ("enable", "disable") and len(words) == 2:
getattr(self.plugins, action)(words[1])
elif action == "reload" and len(words) in (1, 2):
self.plugins.reload(words[1] if len(words) == 2 else None)
else:
raise ValueError("未知命令;输入 help 查看用法")
def _read_console(self):
# stdin 关闭时仍继续运行,适合后台启动。
try:
for line in sys.stdin:
if self.stop_event.is_set():
break
self.commands.put(line.strip())
except (OSError, ValueError):
pass
def run(self, *, seconds=0):
started = time.monotonic()
next_connect = 0
next_rollover = 0
try:
if self.members is not None:
self.members.initialize()
self.plugins.reload()
if self.config.get("console", True):
threading.Thread(target=self._read_console, name="BotConsole", daemon=True).start()
logger.info("框架已启动;输入 help 查看插件管理命令,Ctrl+C 退出")
while not self.stop_event.is_set():
now = time.monotonic()
if self.members is not None and now >= next_rollover:
self.members.rollover()
next_rollover = now + 0.5
if seconds and now - started >= seconds:
break
while True:
try:
command = self.commands.get_nowait()
except queue.Empty:
break
try:
self.execute(command)
except Exception:
logger.exception("控制台命令执行失败")
if self.stop_event.is_set():
break
if self._connection_ready and self.backend.last_error:
self._log_connection_error(self.backend.last_error)
self._disconnect()
next_connect = now + self.config.get("reconnect_seconds", 5)
elif not self._connection_ready and now >= next_connect:
if self.backend is None or self._disconnect():
self._connect()
next_connect = time.monotonic() + self.config.get("reconnect_seconds", 5)
self.stop_event.wait(0.1)
except KeyboardInterrupt:
logger.info("收到退出指令")
finally:
self.stop()
def stop(self):
self.stop_event.set()
self.service.cancel_pending()
self.plugins.shutdown()
cleaned = self._disconnect()
if self.members is not None:
self.members.close()
if not cleaned:
raise RuntimeError("原生连接未能清理;保留 DLL,请查看日志后再启动")
logger.info("框架已停止;连接清理:%s", self.last_cleanup)