正价事件推送,沙盘功能重做

This commit is contained in:
qsc
2026-09-02 23:53:40 +08:00
parent 328b09c24b
commit f28217ec27
15 changed files with 801 additions and 627 deletions
+468
View File
@@ -0,0 +1,468 @@
# 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}"