Files
astrbot_plugin_jx3/core/event_push.py
T
2026-09-03 17:33:28 +08:00

507 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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}"