# pyright: reportOptionalMemberAccess=false import asyncio import contextlib import json from datetime import datetime from typing import Any, Optional from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import aiohttp from aiohttp import ClientSession, ClientTimeout, WSMsgType from astrbot.api import AstrBotConfig, logger from astrbot.api.event import MessageChain from astrbot.api.star import Context from .sqlite import AsyncSQLiteDB from .server_binding import ServerBindingService DEFAULT_WSS_URL = "wss://socket.nicemoe.cn" FREE_EVENT_ACTIONS = frozenset({2001, 2002, 2003, 2004, 2005, 2006}) EVENT_NAMES = { 1001: "奇遇触发", 1002: "马驹刷新", 1003: "马驹捕获", 1005: "扶摇开启", 1006: "扶摇点名", 1008: "的卢每日", 1009: "的卢刷新", 1010: "的卢捕获", 1011: "的卢拍卖", 1012: "副本掉落 / 私货", 1013: "阵营拍卖", 1014: "诛恶事件", 1015: "追魂点名", 1017: "阵营祭祀", 1018: "关隘首领", 1101: "领地宣战·开始", 1102: "领地宣战·结束", 1103: "帮会宣战·开始", 1104: "帮会宣战·结束", 1105: "帮会约战·完胜", 1111: "抢占粮仓", 1112: "大旗重置", 1113: "大旗被夺", 1114: "据点占领", 1115: "据点占领(无帮会)", 1116: "小攻防贡献(非开战)", 1117: "小攻防贡献", 1118: "大攻防贡献", 1119: "战利品竞拍", 1120: "小攻防分红", 1121: "大攻防分红", 1122: "大攻防分红(含指挥)", 1201: "微博更新", 2001: "开服状态", 2002: "官方新闻", 2003: "版本更新", 2004: "八卦速报", 2005: "关隘首领", 2006: "云从预告", } EVENT_ACTIONS = tuple(EVENT_NAMES) class EventPushService: """JX3API WebSocket 事件接收、会话订阅与消息分发。""" def __init__( self, context: Context, config: AstrBotConfig, sqlite: AsyncSQLiteDB, server_binding: ServerBindingService, ): self.context = context self.config = config self.sql = sqlite self.server_binding = server_binding self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip() self.token = str(config.get("jx3api_wss_token", "") or "").strip() self._runner: Optional[asyncio.Task] = None self._session: Optional[ClientSession] = None self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None self._stopping = asyncio.Event() async def initialize(self): await self._init_subscription_table() if self._runner and not self._runner.done(): return self._stopping.clear() self._runner = asyncio.create_task( self._connection_loop(), name="jx3api-event-push", ) async def _init_subscription_table(self): columns = ",\n".join( f"action_{action} INTEGER NOT NULL DEFAULT 0" for action in EVENT_ACTIONS ) await self.sql.execute( f""" CREATE TABLE IF NOT EXISTS event_push_subscriptions ( session_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, {columns}, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) # 以后增加事件编号时,旧数据库也能自动补齐新列。 table_info = await self.sql.fetch_all( "PRAGMA table_info(event_push_subscriptions)" ) existing_columns = {row["name"] for row in table_info} for action in EVENT_ACTIONS: column = self._action_column(action) if column not in existing_columns: await self.sql.execute( f"ALTER TABLE event_push_subscriptions " f"ADD COLUMN {column} INTEGER NOT NULL DEFAULT 0" ) # 旧轮询推送状态不再使用,按迁移要求清理。 await self.sql.execute("DROP TABLE IF EXISTS tuishong") async def stop(self): self._stopping.set() if self._websocket and not self._websocket.closed: await self._websocket.close(code=1000, message=b"plugin stop") if self._runner and not self._runner.done(): self._runner.cancel() with contextlib.suppress(asyncio.CancelledError): await self._runner self._runner = None self._websocket = None if self._session and not self._session.closed: await self._session.close() self._session = None async def _connection_loop(self): retry_count = 0 timeout = ClientTimeout(total=None, sock_connect=15) self._session = ClientSession(timeout=timeout) try: while not self._stopping.is_set(): heartbeat_task = None try: connection_url = self._connection_url() async with self._session.ws_connect( connection_url, heartbeat=45, autoping=True, ) as websocket: self._websocket = websocket retry_count = 0 logger.info("JX3API 事件通道连接成功") heartbeat_task = asyncio.create_task( self._heartbeat_loop(websocket), name="jx3api-event-heartbeat", ) async for message in websocket: if message.type == WSMsgType.TEXT: await self._handle_message(message.data) elif message.type in { WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR, }: break except asyncio.CancelledError: raise except Exception as exc: if not self._stopping.is_set(): error_text = str(exc) if self.token: error_text = error_text.replace(self.token, "***") logger.warning( f"JX3API 事件通道连接异常:" f"{type(exc).__name__}: {error_text}" ) finally: self._websocket = None if heartbeat_task: heartbeat_task.cancel() with contextlib.suppress(asyncio.CancelledError): await heartbeat_task if self._stopping.is_set(): break delay = min(2 ** retry_count, 30) retry_count += 1 logger.info(f"JX3API 事件通道将在 {delay} 秒后重连") try: await asyncio.wait_for(self._stopping.wait(), timeout=delay) except asyncio.TimeoutError: pass finally: if self._session and not self._session.closed: await self._session.close() self._session = None def _connection_url(self) -> str: if not self.token: return self.url parts = urlsplit(self.url) query = dict(parse_qsl(parts.query, keep_blank_values=True)) # JX3API 官方 SDK 当前使用的事件令牌参数名即为 toekn。 query["toekn"] = self.token return urlunsplit( (parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment) ) async def _heartbeat_loop(self, websocket: aiohttp.ClientWebSocketResponse): while not self._stopping.is_set() and not websocket.closed: await asyncio.sleep(30) if not websocket.closed: await websocket.send_json({"action": -1}) async def _handle_message(self, payload: str): try: message = json.loads(payload) except (TypeError, json.JSONDecodeError): logger.debug("忽略 JX3API 事件通道的非 JSON 消息") return if not isinstance(message, dict): return try: action = int(message.get("action")) except (TypeError, ValueError): return if action not in EVENT_NAMES: logger.debug(f"忽略未知 JX3API 事件:{action}") return status = str(message.get("status", "success")).lower() if status != "success": logger.warning(f"JX3API 事件状态异常:action={action}, status={status}") return detail = message.get("detail") if detail is None: detail = message.get("data") if not isinstance(detail, dict): logger.warning(f"JX3API 事件正文结构异常:action={action}") return recipients = await self._enabled_sessions(action) if "server" in detail: event_server = self.server_binding.resolve_server(detail.get("server")) recipients = [ session_id for session_id, bound_server in recipients if not bound_server or self.server_binding.resolve_server(bound_server) == event_server ] else: recipients = [session_id for session_id, _ in recipients] if not recipients: return text = self._format_event(action, detail) results = await asyncio.gather( *(self._send_message(session_id, text) for session_id in recipients), return_exceptions=True, ) for session_id, result in zip(recipients, results): if isinstance(result, Exception): logger.error( f"JX3API 事件推送失败:action={action}, " f"session={session_id}, error={result}" ) async def _send_message(self, session_id: str, text: str): message_chain = MessageChain().message(text) await self.context.send_message(session_id, message_chain) async def _enabled_sessions(self, action: int) -> list[tuple[str, str]]: column = self._action_column(action) rows = await self.sql.fetch_all( f"SELECT subscriptions.session_id, " f"COALESCE(bindings.server, '') AS server " f"FROM event_push_subscriptions AS subscriptions " f"LEFT JOIN session_server_bindings AS bindings " f"ON bindings.session_id=subscriptions.session_id " f"WHERE subscriptions.enabled=1 " f"AND subscriptions.{column}=1" ) return [ (str(row["session_id"]), str(row.get("server") or "").strip()) for row in rows ] async def list_subscription_statuses(self) -> list[dict[str, Any]]: rows = await self.sql.fetch_all( "SELECT * FROM event_push_subscriptions ORDER BY session_id" ) return [ { "session_id": str(row["session_id"]), "enabled": row.get("enabled") == 1, "actions": [ action for action in EVENT_ACTIONS if row.get(self._action_column(action)) == 1 ], } for row in rows ] async def configure( self, session_id: str, first: str = "", second: str = "", ) -> str: first = first.strip().lower() second = second.strip().lower() await self._ensure_session(session_id) if first in {"列表", "list"}: return self._event_list_text() if not first or first in {"状态", "查看", "status"}: return await self._subscription_status(session_id) enable_words = {"开启", "启用", "开", "on"} disable_words = {"关闭", "禁用", "关", "off"} if not second and first in enable_words | disable_words: enabled = first in enable_words await self.sql.update( "event_push_subscriptions", {"enabled": int(enabled), "updated_at": self._now_text()}, "session_id=?", (session_id,), ) state = "已开启" if enabled else "已关闭" return f"当前会话的事件推送总开关{state}。" action_text = first switch_text = second if first in enable_words | disable_words and second.isdigit(): action_text, switch_text = second, first try: action = int(action_text) except ValueError: return self._usage_text() if action not in EVENT_NAMES: return f"不支持事件 {action}。\n" + self._usage_text() if switch_text not in enable_words | disable_words: return self._usage_text() enabled = switch_text in enable_words column = self._action_column(action) await self.sql.update( "event_push_subscriptions", {column: int(enabled), "updated_at": self._now_text()}, "session_id=?", (session_id,), ) state = "已订阅" if enabled else "已取消订阅" result = f"{state}事件 {action}({EVENT_NAMES[action]})。" if enabled and action not in FREE_EVENT_ACTIONS and not self.token: result += "\n该事件需要事件版令牌,当前配置未填写令牌。" return result async def _ensure_session(self, session_id: str): await self.sql.execute( "INSERT OR IGNORE INTO event_push_subscriptions (session_id) VALUES (?)", (session_id,), ) async def _subscription_status(self, session_id: str) -> str: row = await self.sql.select_one( "event_push_subscriptions", "session_id=?", (session_id,), ) if not row: return self._usage_text() subscriptions = [ f"{action} {EVENT_NAMES[action]}" for action in EVENT_ACTIONS if row.get(self._action_column(action)) == 1 ] switch = "开启" if row.get("enabled") == 1 else "关闭" selected = "、".join(subscriptions) if subscriptions else "无" return ( f"事件推送总开关:{switch}\n" f"已订阅事件:{selected}\n\n" f"{self._usage_text()}" ) @staticmethod def _action_column(action: int) -> str: if action not in EVENT_NAMES: raise ValueError(f"不支持事件:{action}") return f"action_{action}" @staticmethod def _now_text() -> str: return datetime.now().strftime("%Y-%m-%d %H:%M:%S") @staticmethod def _format_timestamp(value: Any) -> str: try: timestamp = float(value) if timestamp > 10_000_000_000: timestamp /= 1000 return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") except (TypeError, ValueError, OSError, OverflowError): return str(value or "未知") def _format_event(self, action: int, detail: dict[str, Any]) -> str: if action == 2001: raw_status = detail.get("status") server_status = {0: "维护", 1: "开服", "0": "维护", "1": "开服"}.get( raw_status, str(raw_status or "未知"), ) return ( "【开服状态】\n" f"大区:{detail.get('zone', '未知')}\n" f"服务器:{detail.get('server', '未知')}\n" f"状态:{server_status}\n" f"时间:{self._format_timestamp(detail.get('time'))}" ) if action == 2002: return ( "【官方新闻】\n" f"类型:{detail.get('type', '未知')}\n" f"标题:{detail.get('title', '未知')}\n" f"日期:{detail.get('date', '未知')}\n" f"链接:{detail.get('url', '无')}" ) if action == 2003: return ( "【版本更新】\n" f"当前版本:{detail.get('now_version', '未知')}\n" f"最新版本:{detail.get('new_version', '未知')}\n" f"更新包数量:{detail.get('package_num', '未知')}\n" f"更新大小:{detail.get('package_size', '未知')}" ) if action == 2004: return ( "【八卦速报】\n" f"分类:{detail.get('tags', '未知')}\n" f"区服:{detail.get('zone', '未知')} / {detail.get('server', '未知')}\n" f"来源:{detail.get('tieba', '未知')}\n" f"标题:{detail.get('title', '未知')}\n" f"日期:{detail.get('date', '未知')}\n" f"链接:{detail.get('url', '无')}" ) if action == 2005: return ( "【关隘首领】\n" f"服务器:{detail.get('server', '未知')}\n" f"关卡:{detail.get('stage', '未知')}\n" f"开始时间:{self._format_timestamp(detail.get('start'))}" ) if action == 2006: return ( "【云从预告】\n" f"事件:{detail.get('name', '未知')}\n" f"地点:{detail.get('site', '未知')}\n" f"说明:{detail.get('desc', '无')}\n" f"时间:{self._format_timestamp(detail.get('time'))}" ) detail_text = json.dumps(detail, ensure_ascii=False, indent=2) return f"【事件推送 · {action} {EVENT_NAMES[action]}】\n{detail_text}" @staticmethod def _usage_text() -> str: return ( "用法:\n" "事件推送 开启/关闭\n" "事件推送 事件编号 开启/关闭\n" "事件推送 状态\n" "事件推送 列表" ) @staticmethod def _event_list_text() -> str: free = "\n".join( f"{action}:{EVENT_NAMES[action]}" for action in sorted(FREE_EVENT_ACTIONS) ) paid = "\n".join( f"{action}:{EVENT_NAMES[action]}" for action in EVENT_ACTIONS if action not in FREE_EVENT_ACTIONS ) return f"免费事件:\n{free}\n\n事件版事件:\n{paid}"