469 lines
17 KiB
Python
469 lines
17 KiB
Python
# 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
|
||
|
||
|
||
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,
|
||
):
|
||
self.context = context
|
||
self.config = config
|
||
self.sql = sqlite
|
||
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 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[str]:
|
||
column = self._action_column(action)
|
||
rows = await self.sql.fetch_all(
|
||
f"SELECT session_id FROM event_push_subscriptions "
|
||
f"WHERE enabled=1 AND {column}=1"
|
||
)
|
||
return [str(row["session_id"]) 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}"
|