fxdyz
This commit is contained in:
+176
-25
@@ -4,17 +4,137 @@
|
||||
# pyright: reportOptionalMemberAccess=false
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from typing import Any, Dict
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api import AstrBotConfig
|
||||
|
||||
from .sqlite import AsyncSQLiteDB
|
||||
from .fun_basic import load_template
|
||||
|
||||
|
||||
class BiLeidata:
|
||||
"""按 AstrBot 会话隔离存储本地避雷记录。"""
|
||||
|
||||
LEGACY_SESSION_ID = "__legacy_public__"
|
||||
|
||||
def __init__(self, sqlite:AsyncSQLiteDB):
|
||||
# 引用sqlite
|
||||
self._sql_db = sqlite
|
||||
|
||||
async def initialize(self):
|
||||
"""创建避雷表,并把升级前的数据迁移到历史公共数据区。"""
|
||||
table = await self._sql_db.fetch_one(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
("bilei",),
|
||||
)
|
||||
if not table:
|
||||
await self._sql_db.execute(
|
||||
"""
|
||||
CREATE TABLE bilei (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
text TEXT,
|
||||
time TEXT,
|
||||
user TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
else:
|
||||
columns = await self._sql_db.fetch_all("PRAGMA table_info(bilei)")
|
||||
if "session_id" not in {str(column["name"]) for column in columns}:
|
||||
legacy_count_row = await self._sql_db.fetch_one(
|
||||
"SELECT COUNT(*) AS count FROM bilei"
|
||||
)
|
||||
legacy_count = int((legacy_count_row or {}).get("count", 0))
|
||||
await self._sql_db.execute_transaction(
|
||||
[
|
||||
(
|
||||
"""
|
||||
CREATE TABLE bilei_session_migration (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
text TEXT,
|
||||
time TEXT,
|
||||
user TEXT
|
||||
)
|
||||
""",
|
||||
(),
|
||||
),
|
||||
(
|
||||
"""
|
||||
INSERT INTO bilei_session_migration (
|
||||
id, session_id, name, text, time, user
|
||||
)
|
||||
SELECT id, ?, name, text, time, user FROM bilei
|
||||
""",
|
||||
(self.LEGACY_SESSION_ID,),
|
||||
),
|
||||
("DROP TABLE bilei", ()),
|
||||
("ALTER TABLE bilei_session_migration RENAME TO bilei", ()),
|
||||
]
|
||||
)
|
||||
logger.info(
|
||||
f"已将 {legacy_count} 条旧避雷记录迁移到历史公共数据区"
|
||||
)
|
||||
|
||||
await self._sql_db.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_bilei_session_id_id
|
||||
ON bilei(session_id, id)
|
||||
"""
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_session_id(cls, session_id: Any) -> str:
|
||||
value = str(session_id or "").strip()
|
||||
if not value:
|
||||
raise ValueError("会话 ID 不能为空")
|
||||
if len(value) > 512:
|
||||
raise ValueError("会话 ID 不能超过 512 个字符")
|
||||
if value == cls.LEGACY_SESSION_ID:
|
||||
raise ValueError("历史公共数据区不能作为普通会话访问")
|
||||
return value
|
||||
|
||||
async def list_legacy_records(self) -> list[Dict[str, Any]]:
|
||||
"""列出等待从历史公共数据区迁出的旧版记录。"""
|
||||
return await self._sql_db.fetch_all(
|
||||
"""
|
||||
SELECT id, name, text, time, user
|
||||
FROM bilei
|
||||
WHERE session_id=?
|
||||
ORDER BY id
|
||||
""",
|
||||
(self.LEGACY_SESSION_ID,),
|
||||
)
|
||||
|
||||
async def migrate_legacy_record(
|
||||
self,
|
||||
record_id: Any,
|
||||
target_session_id: Any,
|
||||
) -> None:
|
||||
"""把一条历史记录原子地分配给指定的普通会话。"""
|
||||
if isinstance(record_id, bool):
|
||||
raise ValueError("避雷记录 ID 无效")
|
||||
try:
|
||||
normalized_record_id = int(str(record_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("避雷记录 ID 无效") from None
|
||||
if normalized_record_id <= 0:
|
||||
raise ValueError("避雷记录 ID 无效")
|
||||
|
||||
session_id = self._normalize_session_id(target_session_id)
|
||||
affected = await self._sql_db.execute_affected(
|
||||
"""
|
||||
UPDATE bilei
|
||||
SET session_id=?
|
||||
WHERE id=? AND session_id=?
|
||||
""",
|
||||
(session_id, normalized_record_id, self.LEGACY_SESSION_ID),
|
||||
)
|
||||
if affected != 1:
|
||||
raise ValueError("该历史避雷记录不存在或已完成迁移")
|
||||
|
||||
def _init_return_data(self) -> Dict[str, Any]:
|
||||
"""初始化标准的返回数据结构"""
|
||||
@@ -26,9 +146,16 @@ class BiLeidata:
|
||||
|
||||
|
||||
# --- 业务功能函数 ---
|
||||
async def add(self,name: str, text: str ,user: str) -> Dict[str, Any]:
|
||||
async def add(
|
||||
self,
|
||||
session_id: Any,
|
||||
name: str,
|
||||
text: str,
|
||||
user: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""避雷添加"""
|
||||
return_data = self._init_return_data()
|
||||
session_id = self._normalize_session_id(session_id)
|
||||
|
||||
# 获取系统时间
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -38,6 +165,7 @@ class BiLeidata:
|
||||
await self._sql_db.insert(
|
||||
"bilei",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"name": name,
|
||||
"text": text,
|
||||
"time": now,
|
||||
@@ -63,21 +191,30 @@ class BiLeidata:
|
||||
return return_data
|
||||
|
||||
|
||||
async def all(self) -> Dict[str, Any]:
|
||||
async def all(self, session_id: Any) -> Dict[str, Any]:
|
||||
"""避雷查看"""
|
||||
return_data = self._init_return_data()
|
||||
session_id = self._normalize_session_id(session_id)
|
||||
|
||||
|
||||
# 查询数据
|
||||
try:
|
||||
data = await self._sql_db.select_all("bilei")
|
||||
data = await self._sql_db.fetch_all(
|
||||
"""
|
||||
SELECT id, name, text, time, user
|
||||
FROM bilei
|
||||
WHERE session_id=?
|
||||
ORDER BY id
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"查看避雷失败: {e}")
|
||||
return_data["msg"] = "查看避雷失败"
|
||||
return return_data
|
||||
|
||||
if not data:
|
||||
return_data["msg"] = "未找到避雷数据"
|
||||
return_data["msg"] = "当前会话暂无避雷数据"
|
||||
return return_data
|
||||
|
||||
|
||||
@@ -97,18 +234,23 @@ class BiLeidata:
|
||||
return return_data
|
||||
|
||||
|
||||
async def select(self, name:str) -> Dict[str, Any]:
|
||||
async def select(self, session_id: Any, name: str) -> Dict[str, Any]:
|
||||
"""避雷查询 名称"""
|
||||
return_data = self._init_return_data()
|
||||
session_id = self._normalize_session_id(session_id)
|
||||
|
||||
# 模糊拼接
|
||||
like_name = f"%{name}%"
|
||||
# 查询数据
|
||||
try:
|
||||
data = await self._sql_db.select_all(
|
||||
"bilei",
|
||||
"name LIKE ?",
|
||||
(like_name,)
|
||||
data = await self._sql_db.fetch_all(
|
||||
"""
|
||||
SELECT id, name, text, time, user
|
||||
FROM bilei
|
||||
WHERE session_id=? AND name LIKE ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(session_id, like_name),
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"查询避雷失败: {e}")
|
||||
@@ -116,7 +258,7 @@ class BiLeidata:
|
||||
return return_data
|
||||
|
||||
if not data:
|
||||
return_data["msg"] = "未查询到避雷数据"
|
||||
return_data["msg"] = "当前会话未查询到避雷数据"
|
||||
return return_data
|
||||
|
||||
|
||||
@@ -136,18 +278,26 @@ class BiLeidata:
|
||||
return return_data
|
||||
|
||||
|
||||
async def update(self, id:int, name: str, text: str ,user: str) -> Dict[str, Any]:
|
||||
async def update(
|
||||
self,
|
||||
session_id: Any,
|
||||
id: int,
|
||||
name: str,
|
||||
text: str,
|
||||
user: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""避雷修改 ID 名称 备注"""
|
||||
return_data = self._init_return_data()
|
||||
session_id = self._normalize_session_id(session_id)
|
||||
|
||||
data = await self._sql_db.select_one(
|
||||
"bilei",
|
||||
"id=?",
|
||||
(id,)
|
||||
"session_id=? AND id=?",
|
||||
(session_id, id),
|
||||
)
|
||||
|
||||
if not data:
|
||||
return_data["msg"] = "没有当前ID"
|
||||
return_data["msg"] = "当前会话中不存在该避雷记录"
|
||||
return return_data
|
||||
|
||||
# 获取系统时间
|
||||
@@ -163,8 +313,8 @@ class BiLeidata:
|
||||
"time": now,
|
||||
"user": user,
|
||||
},
|
||||
"id=?",
|
||||
(id,)
|
||||
"session_id=? AND id=?",
|
||||
(session_id, id),
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
@@ -186,26 +336,27 @@ class BiLeidata:
|
||||
return return_data
|
||||
|
||||
|
||||
async def delete(self, id:int) -> Dict[str, Any]:
|
||||
async def delete(self, session_id: Any, id: int) -> Dict[str, Any]:
|
||||
"""避雷删除 ID"""
|
||||
return_data = self._init_return_data()
|
||||
session_id = self._normalize_session_id(session_id)
|
||||
|
||||
data = await self._sql_db.select_one(
|
||||
"bilei",
|
||||
"id=?",
|
||||
(id,)
|
||||
"session_id=? AND id=?",
|
||||
(session_id, id),
|
||||
)
|
||||
|
||||
if not data:
|
||||
return_data["msg"] = "没有当前ID"
|
||||
return_data["msg"] = "当前会话中不存在该避雷记录"
|
||||
return return_data
|
||||
|
||||
# 删除
|
||||
try:
|
||||
await self._sql_db.delete(
|
||||
"bilei",
|
||||
"id=?",
|
||||
(id,)
|
||||
"session_id=? AND id=?",
|
||||
(session_id, id),
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
@@ -217,4 +368,4 @@ class BiLeidata:
|
||||
|
||||
return_data["code"] = 200
|
||||
|
||||
return return_data
|
||||
return return_data
|
||||
|
||||
+916
@@ -0,0 +1,916 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
from .sqlite import AsyncSQLiteDB
|
||||
|
||||
API_ENDPOINTS: tuple[str, ...] = (
|
||||
"/active/calendar",
|
||||
"/active/celebs",
|
||||
"/arena/awesome",
|
||||
"/arena/recent",
|
||||
"/arena/schools",
|
||||
"/auction/records",
|
||||
"/battle/records",
|
||||
"/card/cached",
|
||||
"/card/random",
|
||||
"/card/records",
|
||||
"/castle/status",
|
||||
"/chat/records",
|
||||
"/chitu/records",
|
||||
"/chitu/week/records",
|
||||
"/duowan/statistics",
|
||||
"/event/collect",
|
||||
"/event/missing",
|
||||
"/event/recent",
|
||||
"/event/records",
|
||||
"/event/statistics",
|
||||
"/exam/search",
|
||||
"/fenxian/records",
|
||||
"/firework/records",
|
||||
"/food/list",
|
||||
"/fraud/detail",
|
||||
"/home/flower",
|
||||
"/home/furniture",
|
||||
"/home/travel",
|
||||
"/mech/decrypt",
|
||||
"/mentor/search",
|
||||
"/monster/records",
|
||||
"/monster/weekly",
|
||||
"/news/announce",
|
||||
"/news/records",
|
||||
"/raid/records",
|
||||
"/ranch/chat",
|
||||
"/ranch/records",
|
||||
"/rank/arena",
|
||||
"/rank/championship",
|
||||
"/rank/constable",
|
||||
"/rank/outlaw",
|
||||
"/rank/statistics",
|
||||
"/rank/trials",
|
||||
"/rank/wanted",
|
||||
"/recruit/search",
|
||||
"/reward/statistics",
|
||||
"/role/achievement",
|
||||
"/role/detail",
|
||||
"/sand/records",
|
||||
"/saohua/answer",
|
||||
"/saohua/content",
|
||||
"/saohua/context",
|
||||
"/saohua/drink",
|
||||
"/saohua/eat",
|
||||
"/saohua/random",
|
||||
"/saohua/zhanan",
|
||||
"/school/matrix",
|
||||
"/school/seniority",
|
||||
"/school/skills",
|
||||
"/school/talent",
|
||||
"/server/status/check",
|
||||
"/skill/rework",
|
||||
"/steed/records",
|
||||
"/tieba/item/records",
|
||||
"/tieba/random",
|
||||
"/trade/demon",
|
||||
"/trade/manufacture",
|
||||
"/trade/records",
|
||||
"/trade/wanbaolou",
|
||||
"/tuilan/achievement",
|
||||
"/wicked/records",
|
||||
)
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""持久化接口 JSON 与 HTML 渲染图片,并提供 WebUI 配置。"""
|
||||
|
||||
DEFAULT_API_TTL = 300
|
||||
DEFAULT_IMAGE_TTL = 600
|
||||
MAX_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
DEFAULT_MAX_MEMORY_ENTRIES = 256
|
||||
DEFAULT_MAX_IMAGE_BYTES = 512 * 1024 * 1024
|
||||
MAX_MEMORY_ENTRIES_LIMIT = 100_000
|
||||
MAX_IMAGE_MB_LIMIT = 10_240
|
||||
STALE_RETENTION_SECONDS = 7 * 24 * 60 * 60
|
||||
_SENSITIVE_KEYS = frozenset(
|
||||
{"token", "ticket", "authorization", "access_token", "jx3api_token"}
|
||||
)
|
||||
_NO_CACHE_API_DEFAULTS = frozenset(
|
||||
{
|
||||
"/card/random",
|
||||
"/saohua/answer",
|
||||
"/saohua/content",
|
||||
"/saohua/context",
|
||||
"/saohua/drink",
|
||||
"/saohua/eat",
|
||||
"/saohua/random",
|
||||
"/saohua/zhanan",
|
||||
"/tieba/random",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sqlite: AsyncSQLiteDB,
|
||||
image_dir: Path,
|
||||
asset_roots: Iterable[Path] = (),
|
||||
):
|
||||
self._sqlite = sqlite
|
||||
self.image_dir = Path(image_dir)
|
||||
self._settings: dict[tuple[str, str], int] = {}
|
||||
self.max_memory_entries = self.DEFAULT_MAX_MEMORY_ENTRIES
|
||||
self.max_image_bytes = self.DEFAULT_MAX_IMAGE_BYTES
|
||||
self._memory: OrderedDict[str, tuple[int, int, str]] = OrderedDict()
|
||||
self._api_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
self._image_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
self._command_context: ContextVar[tuple[str, str]] = ContextVar(
|
||||
"jx3_cache_command_context",
|
||||
default=("", ""),
|
||||
)
|
||||
self._image_names: set[str] = set()
|
||||
self._asset_signature = self._build_asset_signature(asset_roots)
|
||||
|
||||
@staticmethod
|
||||
def _build_asset_signature(roots: Iterable[Path]) -> str:
|
||||
parts: list[str] = []
|
||||
for root in roots:
|
||||
path = Path(root)
|
||||
if not path.exists():
|
||||
continue
|
||||
for item in sorted(
|
||||
candidate for candidate in path.rglob("*") if candidate.is_file()
|
||||
):
|
||||
try:
|
||||
stat = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
parts.append(
|
||||
f"{item.relative_to(path)}:{stat.st_size}:{stat.st_mtime_ns}"
|
||||
)
|
||||
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
async def initialize(self):
|
||||
self.image_dir.mkdir(parents=True, exist_ok=True)
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cache_settings(
|
||||
cache_type TEXT NOT NULL,
|
||||
cache_name TEXT NOT NULL,
|
||||
ttl_seconds INTEGER NOT NULL,
|
||||
PRIMARY KEY(cache_type, cache_name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cache_limits(
|
||||
limit_name TEXT PRIMARY KEY,
|
||||
limit_value INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS api_response_cache(
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_accessed_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS image_render_cache(
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
cache_name TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_accessed_at INTEGER NOT NULL,
|
||||
message_text TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
image_columns = await self._sqlite.fetch_all(
|
||||
"PRAGMA table_info(image_render_cache)"
|
||||
)
|
||||
if "message_text" not in {str(row.get("name")) for row in image_columns}:
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
ALTER TABLE image_render_cache
|
||||
ADD COLUMN message_text TEXT NOT NULL DEFAULT ''
|
||||
"""
|
||||
)
|
||||
await self._sqlite.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_cache_endpoint ON api_response_cache(endpoint)"
|
||||
)
|
||||
await self._sqlite.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_image_cache_name ON image_render_cache(cache_name)"
|
||||
)
|
||||
await self._load_settings()
|
||||
await self._load_limits()
|
||||
await self.cleanup_expired()
|
||||
self._enforce_memory_limit()
|
||||
await self._enforce_image_limit()
|
||||
|
||||
async def _load_settings(self):
|
||||
rows = await self._sqlite.select_all("cache_settings")
|
||||
self._settings = {
|
||||
(str(row["cache_type"]), str(row["cache_name"])): int(row["ttl_seconds"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
async def _load_limits(self):
|
||||
rows = await self._sqlite.select_all("cache_limits")
|
||||
limits = {str(row["limit_name"]): int(row["limit_value"]) for row in rows}
|
||||
self.max_memory_entries = self._validated_memory_limit(
|
||||
limits.get("api_memory_entries", self.DEFAULT_MAX_MEMORY_ENTRIES)
|
||||
)
|
||||
image_limit_mb = self._validated_image_limit_mb(
|
||||
limits.get("image_max_mb", self.DEFAULT_MAX_IMAGE_BYTES // 1024 // 1024)
|
||||
)
|
||||
self.max_image_bytes = image_limit_mb * 1024 * 1024
|
||||
|
||||
@classmethod
|
||||
def _validated_memory_limit(cls, value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("接口内存缓存条数必须是整数")
|
||||
try:
|
||||
limit = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("接口内存缓存条数必须是整数") from exc
|
||||
if limit < 1 or limit > cls.MAX_MEMORY_ENTRIES_LIMIT:
|
||||
raise ValueError("接口内存缓存条数必须在 1 到 100000 之间")
|
||||
return limit
|
||||
|
||||
@classmethod
|
||||
def _validated_image_limit_mb(cls, value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("图片缓存容量必须是整数 MB")
|
||||
try:
|
||||
limit = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("图片缓存容量必须是整数 MB") from exc
|
||||
if limit < 1 or limit > cls.MAX_IMAGE_MB_LIMIT:
|
||||
raise ValueError("图片缓存容量必须在 1 到 10240 MB 之间")
|
||||
return limit
|
||||
|
||||
async def set_limits(self, api_memory_entries: Any, image_max_mb: Any):
|
||||
memory_limit = self._validated_memory_limit(api_memory_entries)
|
||||
image_limit_mb = self._validated_image_limit_mb(image_max_mb)
|
||||
for limit_name, limit_value in (
|
||||
("api_memory_entries", memory_limit),
|
||||
("image_max_mb", image_limit_mb),
|
||||
):
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
INSERT INTO cache_limits(limit_name, limit_value)
|
||||
VALUES(?, ?)
|
||||
ON CONFLICT(limit_name) DO UPDATE SET
|
||||
limit_value=excluded.limit_value
|
||||
""",
|
||||
(limit_name, limit_value),
|
||||
)
|
||||
self.max_memory_entries = memory_limit
|
||||
self.max_image_bytes = image_limit_mb * 1024 * 1024
|
||||
self._enforce_memory_limit()
|
||||
await self._enforce_image_limit()
|
||||
|
||||
def register_image_names(self, names: Iterable[str]):
|
||||
self._image_names.update(
|
||||
str(name).strip() for name in names if str(name).strip()
|
||||
)
|
||||
|
||||
def enter_command(self, command_name: str, args: Iterable[Any] = ()) -> Token:
|
||||
argument_signature = hashlib.sha256(
|
||||
self._json(list(args)).encode("utf-8")
|
||||
).hexdigest()
|
||||
return self._command_context.set(
|
||||
(str(command_name or "").strip(), argument_signature)
|
||||
)
|
||||
|
||||
def leave_command(self, token: Token):
|
||||
self._command_context.reset(token)
|
||||
|
||||
def current_command(self) -> str:
|
||||
return self._command_context.get()[0]
|
||||
|
||||
def current_command_signature(self) -> str:
|
||||
return self._command_context.get()[1]
|
||||
|
||||
def _base_ttl(self, cache_type: str, cache_name: str) -> int:
|
||||
if cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS:
|
||||
return 0
|
||||
# 会话避雷图片默认不缓存,避免修改记录后仍展示旧图;仍可在 WebUI 单独开启。
|
||||
if cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}:
|
||||
return 0
|
||||
return self.DEFAULT_API_TTL if cache_type == "api" else self.DEFAULT_IMAGE_TTL
|
||||
|
||||
def get_ttl(self, cache_type: str, cache_name: str) -> int:
|
||||
specific = self._settings.get((cache_type, cache_name))
|
||||
if specific is not None:
|
||||
return specific
|
||||
if cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS:
|
||||
return 0
|
||||
if cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}:
|
||||
return 0
|
||||
default = self._settings.get((cache_type, "*"))
|
||||
if default is not None:
|
||||
return default
|
||||
return self._base_ttl(cache_type, cache_name)
|
||||
|
||||
@classmethod
|
||||
def _validate_ttl(cls, value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("缓存时间必须是整数秒")
|
||||
try:
|
||||
ttl = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("缓存时间必须是整数秒") from exc
|
||||
if ttl < 0 or ttl > cls.MAX_TTL_SECONDS:
|
||||
raise ValueError("缓存时间必须在 0 到 2592000 秒之间")
|
||||
return ttl
|
||||
|
||||
async def set_ttl(
|
||||
self,
|
||||
cache_type: str,
|
||||
cache_name: str,
|
||||
ttl_seconds: Any = None,
|
||||
inherit: bool = False,
|
||||
):
|
||||
if cache_type not in {"api", "image"}:
|
||||
raise ValueError("缓存类型仅支持 api 或 image")
|
||||
cache_name = str(cache_name or "").strip()
|
||||
if not cache_name:
|
||||
raise ValueError("缓存项目不能为空")
|
||||
if cache_name == "*" and inherit:
|
||||
raise ValueError("默认缓存时间不能继承")
|
||||
|
||||
if inherit:
|
||||
await self._sqlite.delete(
|
||||
"cache_settings",
|
||||
"cache_type=? AND cache_name=?",
|
||||
(cache_type, cache_name),
|
||||
)
|
||||
self._settings.pop((cache_type, cache_name), None)
|
||||
return
|
||||
|
||||
ttl = self._validate_ttl(ttl_seconds)
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
INSERT INTO cache_settings(cache_type, cache_name, ttl_seconds)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(cache_type, cache_name) DO UPDATE SET
|
||||
ttl_seconds=excluded.ttl_seconds
|
||||
""",
|
||||
(cache_type, cache_name, ttl),
|
||||
)
|
||||
self._settings[(cache_type, cache_name)] = ttl
|
||||
|
||||
@classmethod
|
||||
def _normalized(cls, value: Any, strip_sensitive: bool = False) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): cls._normalized(item, strip_sensitive)
|
||||
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
||||
if not strip_sensitive or str(key).lower() not in cls._SENSITIVE_KEYS
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [cls._normalized(item, strip_sensitive) for item in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def _json(cls, value: Any, strip_sensitive: bool = False) -> str:
|
||||
return json.dumps(
|
||||
cls._normalized(value, strip_sensitive),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def build_api_key(cls, endpoint: str, params: dict[str, Any]) -> str:
|
||||
source = f"api:v1|{endpoint}|{cls._json(params, strip_sensitive=True)}"
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _read_api_payload(
|
||||
self,
|
||||
cache_key: str,
|
||||
endpoint: str,
|
||||
allow_expired: bool = False,
|
||||
) -> tuple[Any | None, int | None, int | None]:
|
||||
now = int(time.time())
|
||||
ttl = self.get_ttl("api", endpoint)
|
||||
memory = self._memory.get(cache_key)
|
||||
if memory is not None:
|
||||
created_at, expires_at, payload = memory
|
||||
effective_expiry = min(expires_at, created_at + ttl)
|
||||
if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS:
|
||||
self._memory.pop(cache_key, None)
|
||||
elif allow_expired or effective_expiry > now:
|
||||
self._memory.move_to_end(cache_key)
|
||||
try:
|
||||
return json.loads(payload), effective_expiry, created_at
|
||||
except json.JSONDecodeError:
|
||||
self._memory.pop(cache_key, None)
|
||||
|
||||
row = await self._sqlite.fetch_one(
|
||||
"""
|
||||
SELECT payload, created_at, expires_at
|
||||
FROM api_response_cache
|
||||
WHERE cache_key=? AND endpoint=?
|
||||
""",
|
||||
(cache_key, endpoint),
|
||||
)
|
||||
if not row:
|
||||
return None, None, None
|
||||
expires_at = int(row["expires_at"])
|
||||
created_at = int(row["created_at"])
|
||||
effective_expiry = min(expires_at, created_at + ttl)
|
||||
if not allow_expired and effective_expiry <= now:
|
||||
return None, effective_expiry, created_at
|
||||
if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS:
|
||||
await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,))
|
||||
return None, effective_expiry, created_at
|
||||
try:
|
||||
data = json.loads(str(row["payload"]))
|
||||
except json.JSONDecodeError:
|
||||
await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,))
|
||||
return None, None, None
|
||||
|
||||
self._remember(cache_key, created_at, expires_at, str(row["payload"]))
|
||||
await self._sqlite.execute(
|
||||
"UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?",
|
||||
(now, cache_key),
|
||||
)
|
||||
return data, effective_expiry, created_at
|
||||
|
||||
def _remember(self, cache_key: str, created_at: int, expires_at: int, payload: str):
|
||||
self._memory[cache_key] = (created_at, expires_at, payload)
|
||||
self._memory.move_to_end(cache_key)
|
||||
self._enforce_memory_limit()
|
||||
|
||||
def _enforce_memory_limit(self):
|
||||
while len(self._memory) > self.max_memory_entries:
|
||||
self._memory.popitem(last=False)
|
||||
|
||||
async def _save_api_payload(
|
||||
self,
|
||||
cache_key: str,
|
||||
endpoint: str,
|
||||
data: Any,
|
||||
ttl_seconds: int,
|
||||
) -> int:
|
||||
payload = self._json(data)
|
||||
now = int(time.time())
|
||||
expires_at = now + ttl_seconds
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
INSERT INTO api_response_cache(
|
||||
cache_key, endpoint, payload, created_at, expires_at, last_accessed_at
|
||||
) VALUES(?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
endpoint=excluded.endpoint,
|
||||
payload=excluded.payload,
|
||||
created_at=excluded.created_at,
|
||||
expires_at=excluded.expires_at,
|
||||
last_accessed_at=excluded.last_accessed_at
|
||||
""",
|
||||
(cache_key, endpoint, payload, now, expires_at, now),
|
||||
)
|
||||
self._remember(cache_key, now, expires_at, payload)
|
||||
return now
|
||||
|
||||
async def request_api(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict[str, Any],
|
||||
requester: Callable[[], Awaitable[Any]],
|
||||
is_cacheable: Callable[[Any], bool],
|
||||
force_refresh: bool = False,
|
||||
allow_stale: bool = True,
|
||||
) -> tuple[Any, dict[str, Any]]:
|
||||
ttl = self.get_ttl("api", endpoint)
|
||||
cache_key = self.build_api_key(endpoint, params)
|
||||
metadata = {
|
||||
"endpoint": endpoint,
|
||||
"cache_key": cache_key,
|
||||
"hit": False,
|
||||
"stale": False,
|
||||
"ttl_seconds": ttl,
|
||||
"data_hash": "",
|
||||
"created_at": None,
|
||||
}
|
||||
if ttl <= 0:
|
||||
data = await requester()
|
||||
metadata["created_at"] = int(time.time())
|
||||
if is_cacheable(data):
|
||||
metadata["data_hash"] = hashlib.sha256(
|
||||
self._json(data).encode("utf-8")
|
||||
).hexdigest()
|
||||
return data, metadata
|
||||
|
||||
if not force_refresh:
|
||||
cached, _, created_at = await self._read_api_payload(cache_key, endpoint)
|
||||
if cached is not None:
|
||||
metadata["hit"] = True
|
||||
metadata["created_at"] = created_at
|
||||
metadata["data_hash"] = hashlib.sha256(
|
||||
self._json(cached).encode("utf-8")
|
||||
).hexdigest()
|
||||
return cached, metadata
|
||||
|
||||
lock = self._api_locks.setdefault(cache_key, asyncio.Lock())
|
||||
async with lock:
|
||||
if not force_refresh:
|
||||
cached, _, created_at = await self._read_api_payload(cache_key, endpoint)
|
||||
if cached is not None:
|
||||
metadata["hit"] = True
|
||||
metadata["created_at"] = created_at
|
||||
metadata["data_hash"] = hashlib.sha256(
|
||||
self._json(cached).encode("utf-8")
|
||||
).hexdigest()
|
||||
return cached, metadata
|
||||
|
||||
stale, _, stale_created_at = await self._read_api_payload(
|
||||
cache_key,
|
||||
endpoint,
|
||||
allow_expired=True,
|
||||
)
|
||||
data = await requester()
|
||||
if is_cacheable(data):
|
||||
metadata["data_hash"] = hashlib.sha256(
|
||||
self._json(data).encode("utf-8")
|
||||
).hexdigest()
|
||||
try:
|
||||
metadata["created_at"] = await self._save_api_payload(
|
||||
cache_key,
|
||||
endpoint,
|
||||
data,
|
||||
ttl,
|
||||
)
|
||||
except Exception as exc:
|
||||
metadata["created_at"] = int(time.time())
|
||||
logger.warning(f"写入接口缓存失败 endpoint={endpoint}: {exc}")
|
||||
return data, metadata
|
||||
if stale is not None and allow_stale:
|
||||
metadata["hit"] = True
|
||||
metadata["stale"] = True
|
||||
metadata["created_at"] = stale_created_at
|
||||
metadata["data_hash"] = hashlib.sha256(
|
||||
self._json(stale).encode("utf-8")
|
||||
).hexdigest()
|
||||
logger.warning(f"JX3API 请求失败,使用过期缓存:{endpoint}")
|
||||
return stale, metadata
|
||||
return data, metadata
|
||||
|
||||
def build_image_key(
|
||||
self,
|
||||
cache_name: str,
|
||||
template: str,
|
||||
data: dict[str, Any],
|
||||
render_options: dict[str, Any],
|
||||
source_signature: str = "",
|
||||
variant_signature: str = "",
|
||||
) -> str:
|
||||
source = "|".join(
|
||||
(
|
||||
"image:v2",
|
||||
cache_name,
|
||||
hashlib.sha256(template.encode("utf-8")).hexdigest(),
|
||||
source_signature
|
||||
or hashlib.sha256(self._json(data).encode("utf-8")).hexdigest(),
|
||||
variant_signature,
|
||||
self._json(render_options),
|
||||
self._asset_signature,
|
||||
)
|
||||
)
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def value_signature(cls, value: Any) -> str:
|
||||
return hashlib.sha256(cls._json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
def build_image_request_key(
|
||||
self,
|
||||
cache_name: str,
|
||||
render_options: dict[str, Any],
|
||||
variant_signature: str,
|
||||
scope_signature: str = "",
|
||||
) -> str:
|
||||
"""生成可在请求接口前计算的最终图片缓存键。"""
|
||||
source = "|".join(
|
||||
(
|
||||
"image-request:v2",
|
||||
cache_name,
|
||||
variant_signature,
|
||||
scope_signature,
|
||||
self._json(render_options),
|
||||
self._asset_signature,
|
||||
)
|
||||
)
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
|
||||
def image_lock(self, cache_key: str) -> asyncio.Lock:
|
||||
return self._image_locks.setdefault(cache_key, asyncio.Lock())
|
||||
|
||||
async def get_image_entry(
|
||||
self,
|
||||
cache_key: str,
|
||||
cache_name: str,
|
||||
) -> tuple[Path, str] | None:
|
||||
try:
|
||||
ttl = self.get_ttl("image", cache_name)
|
||||
if ttl <= 0:
|
||||
return None
|
||||
now = int(time.time())
|
||||
row = await self._sqlite.fetch_one(
|
||||
"""
|
||||
SELECT file_name, created_at, expires_at, message_text
|
||||
FROM image_render_cache
|
||||
WHERE cache_key=? AND cache_name=?
|
||||
""",
|
||||
(cache_key, cache_name),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
effective_expiry = min(int(row["expires_at"]), int(row["created_at"]) + ttl)
|
||||
path = self.image_dir / str(row["file_name"])
|
||||
if effective_expiry <= now or not path.is_file():
|
||||
await self._delete_image_record(cache_key, path)
|
||||
return None
|
||||
await self._sqlite.execute(
|
||||
"UPDATE image_render_cache SET last_accessed_at=? WHERE cache_key=?",
|
||||
(now, cache_key),
|
||||
)
|
||||
return path, str(row.get("message_text") or "")
|
||||
except Exception as exc:
|
||||
logger.warning(f"读取图片缓存失败 cache={cache_name}: {exc}")
|
||||
return None
|
||||
|
||||
async def get_image(self, cache_key: str, cache_name: str) -> Path | None:
|
||||
entry = await self.get_image_entry(cache_key, cache_name)
|
||||
return entry[0] if entry else None
|
||||
|
||||
async def save_image(
|
||||
self,
|
||||
cache_key: str,
|
||||
cache_name: str,
|
||||
source_path: str,
|
||||
image_format: str,
|
||||
message_text: str = "",
|
||||
) -> Path | None:
|
||||
ttl = self.get_ttl("image", cache_name)
|
||||
source = Path(source_path)
|
||||
if ttl <= 0 or not source.is_file():
|
||||
return None
|
||||
extension = "jpg" if image_format == "jpeg" else "png"
|
||||
file_name = f"{cache_key}.{extension}"
|
||||
target = self.image_dir / file_name
|
||||
temporary = self.image_dir / f".{file_name}.tmp"
|
||||
try:
|
||||
await asyncio.to_thread(shutil.copy2, source, temporary)
|
||||
await asyncio.to_thread(os.replace, temporary, target)
|
||||
size_bytes = target.stat().st_size
|
||||
now = int(time.time())
|
||||
await self._sqlite.execute(
|
||||
"""
|
||||
INSERT INTO image_render_cache(
|
||||
cache_key, cache_name, file_name, size_bytes,
|
||||
created_at, expires_at, last_accessed_at, message_text
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
cache_name=excluded.cache_name,
|
||||
file_name=excluded.file_name,
|
||||
size_bytes=excluded.size_bytes,
|
||||
created_at=excluded.created_at,
|
||||
expires_at=excluded.expires_at,
|
||||
last_accessed_at=excluded.last_accessed_at,
|
||||
message_text=excluded.message_text
|
||||
""",
|
||||
(
|
||||
cache_key,
|
||||
cache_name,
|
||||
file_name,
|
||||
size_bytes,
|
||||
now,
|
||||
now + ttl,
|
||||
now,
|
||||
str(message_text or ""),
|
||||
),
|
||||
)
|
||||
await self._enforce_image_limit()
|
||||
return target
|
||||
except Exception as exc:
|
||||
logger.warning(f"保存图片缓存失败 cache={cache_name}: {exc}")
|
||||
for path in (temporary, target):
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _delete_image_record(self, cache_key: str, path: Path):
|
||||
await self._sqlite.delete("image_render_cache", "cache_key=?", (cache_key,))
|
||||
try:
|
||||
if path.is_file() and path.parent.resolve() == self.image_dir.resolve():
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _enforce_image_limit(self):
|
||||
row = await self._sqlite.fetch_one(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) AS total FROM image_render_cache"
|
||||
)
|
||||
total = int((row or {}).get("total") or 0)
|
||||
if total <= self.max_image_bytes:
|
||||
return
|
||||
rows = await self._sqlite.fetch_all(
|
||||
"""
|
||||
SELECT cache_key, file_name, size_bytes
|
||||
FROM image_render_cache
|
||||
ORDER BY last_accessed_at ASC
|
||||
"""
|
||||
)
|
||||
for item in rows:
|
||||
if total <= self.max_image_bytes:
|
||||
break
|
||||
await self._delete_image_record(
|
||||
str(item["cache_key"]),
|
||||
self.image_dir / str(item["file_name"]),
|
||||
)
|
||||
total -= int(item["size_bytes"])
|
||||
|
||||
async def cleanup_expired(self):
|
||||
now = int(time.time())
|
||||
self._memory = OrderedDict(
|
||||
(key, value) for key, value in self._memory.items() if value[1] > now
|
||||
)
|
||||
await self._sqlite.delete(
|
||||
"api_response_cache",
|
||||
"expires_at<=?",
|
||||
(now - self.STALE_RETENTION_SECONDS,),
|
||||
)
|
||||
rows = await self._sqlite.fetch_all(
|
||||
"SELECT cache_key, file_name FROM image_render_cache WHERE expires_at<=?",
|
||||
(now,),
|
||||
)
|
||||
for row in rows:
|
||||
await self._delete_image_record(
|
||||
str(row["cache_key"]),
|
||||
self.image_dir / str(row["file_name"]),
|
||||
)
|
||||
|
||||
async def clear(self, cache_type: str) -> dict[str, int]:
|
||||
if cache_type not in {"api", "image", "all"}:
|
||||
raise ValueError("清理类型仅支持 api、image 或 all")
|
||||
removed = {"api": 0, "image": 0}
|
||||
if cache_type in {"api", "all"}:
|
||||
row = await self._sqlite.fetch_one(
|
||||
"SELECT COUNT(*) AS count FROM api_response_cache"
|
||||
)
|
||||
removed["api"] = int((row or {}).get("count") or 0)
|
||||
await self._sqlite.execute("DELETE FROM api_response_cache")
|
||||
self._memory.clear()
|
||||
if cache_type in {"image", "all"}:
|
||||
rows = await self._sqlite.fetch_all(
|
||||
"SELECT cache_key, file_name FROM image_render_cache"
|
||||
)
|
||||
removed["image"] = len(rows)
|
||||
for row in rows:
|
||||
await self._delete_image_record(
|
||||
str(row["cache_key"]),
|
||||
self.image_dir / str(row["file_name"]),
|
||||
)
|
||||
return removed
|
||||
|
||||
async def clear_item(self, cache_type: str, cache_name: str) -> int:
|
||||
if cache_type not in {"api", "image"}:
|
||||
raise ValueError("缓存类型仅支持 api 或 image")
|
||||
cache_name = str(cache_name or "").strip()
|
||||
if not cache_name:
|
||||
raise ValueError("缓存项目不能为空")
|
||||
|
||||
if cache_type == "api":
|
||||
rows = await self._sqlite.fetch_all(
|
||||
"SELECT cache_key FROM api_response_cache WHERE endpoint=?",
|
||||
(cache_name,),
|
||||
)
|
||||
await self._sqlite.delete(
|
||||
"api_response_cache",
|
||||
"endpoint=?",
|
||||
(cache_name,),
|
||||
)
|
||||
for row in rows:
|
||||
self._memory.pop(str(row["cache_key"]), None)
|
||||
return len(rows)
|
||||
|
||||
rows = await self._sqlite.fetch_all(
|
||||
"""
|
||||
SELECT cache_key, file_name
|
||||
FROM image_render_cache
|
||||
WHERE cache_name=?
|
||||
""",
|
||||
(cache_name,),
|
||||
)
|
||||
for row in rows:
|
||||
await self._delete_image_record(
|
||||
str(row["cache_key"]),
|
||||
self.image_dir / str(row["file_name"]),
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
def _setting_item(self, cache_type: str, cache_name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": cache_name,
|
||||
"ttl_seconds": self.get_ttl(cache_type, cache_name),
|
||||
"overridden": (cache_type, cache_name) in self._settings,
|
||||
"safe_default": (
|
||||
(
|
||||
(cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS)
|
||||
or (
|
||||
cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}
|
||||
)
|
||||
)
|
||||
and (cache_type, cache_name) not in self._settings
|
||||
),
|
||||
}
|
||||
|
||||
async def dashboard(self) -> dict[str, Any]:
|
||||
await self.cleanup_expired()
|
||||
api_row = await self._sqlite.fetch_one(
|
||||
"""
|
||||
SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS size_bytes
|
||||
FROM api_response_cache
|
||||
"""
|
||||
)
|
||||
image_row = await self._sqlite.fetch_one(
|
||||
"""
|
||||
SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(size_bytes), 0) AS size_bytes
|
||||
FROM image_render_cache
|
||||
"""
|
||||
)
|
||||
known_api_names = set(API_ENDPOINTS)
|
||||
known_api_names.update(
|
||||
name
|
||||
for cache_type, name in self._settings
|
||||
if cache_type == "api" and name != "*"
|
||||
)
|
||||
known_image_names = set(self._image_names)
|
||||
known_image_names.update(
|
||||
name
|
||||
for cache_type, name in self._settings
|
||||
if cache_type == "image" and name != "*"
|
||||
)
|
||||
return {
|
||||
"defaults": {
|
||||
"api": self.get_ttl("api", "*"),
|
||||
"image": self.get_ttl("image", "*"),
|
||||
},
|
||||
"limits": {
|
||||
"api_memory_entries": self.max_memory_entries,
|
||||
"image_max_mb": self.max_image_bytes // 1024 // 1024,
|
||||
},
|
||||
"api": [
|
||||
self._setting_item("api", name) for name in sorted(known_api_names)
|
||||
],
|
||||
"images": [
|
||||
self._setting_item("image", name)
|
||||
for name in sorted(
|
||||
known_image_names, key=lambda value: value.encode("utf-8")
|
||||
)
|
||||
],
|
||||
"stats": {
|
||||
"api_count": int((api_row or {}).get("count") or 0),
|
||||
"api_size_bytes": int((api_row or {}).get("size_bytes") or 0),
|
||||
"api_memory_count": len(self._memory),
|
||||
"api_memory_limit": self.max_memory_entries,
|
||||
"image_count": int((image_row or {}).get("count") or 0),
|
||||
"image_size_bytes": int((image_row or {}).get("size_bytes") or 0),
|
||||
"image_limit_bytes": self.max_image_bytes,
|
||||
},
|
||||
}
|
||||
+96
-9
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import html
|
||||
import re
|
||||
import hashlib
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from typing import TYPE_CHECKING, Dict, Any, Optional, List, Union
|
||||
from inspect import isawaitable
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
@@ -14,6 +17,9 @@ from .request import APIClient, APIErrorResponse
|
||||
from .sqlite import AsyncSQLiteDB
|
||||
from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .cache import CacheService
|
||||
|
||||
|
||||
ROLE_RANK_NAMES = {
|
||||
"名士五十强",
|
||||
@@ -74,25 +80,33 @@ RANK_NAMES = frozenset().union(
|
||||
|
||||
|
||||
class JX3APIService:
|
||||
def __init__(self, config: AstrBotConfig, sqlite: AsyncSQLiteDB):
|
||||
def __init__(
|
||||
self,
|
||||
config: AstrBotConfig,
|
||||
sqlite: AsyncSQLiteDB,
|
||||
cache: Optional["CacheService"] = None,
|
||||
):
|
||||
# 实例化 API Client
|
||||
self._api: APIClient = APIClient()
|
||||
# 引用插件配置文件
|
||||
self._config = config
|
||||
# 引用sqlite
|
||||
self._sql_db = sqlite
|
||||
self._cache = cache
|
||||
self._token_stats_cache: tuple[float, Dict[str, Any]] | None = None
|
||||
self._token_stats_lock = asyncio.Lock()
|
||||
# 获取配置中的 Token
|
||||
self.token = self._config.get("jx3api_token", "")
|
||||
if self.token == "":
|
||||
logger.warning("获取配置token失败,请正确填写token,否则部分功能无法正常使用")
|
||||
else:
|
||||
logger.debug(f"获取配置token成功。{self.token}")
|
||||
logger.debug(f"获取配置token成功。")
|
||||
# 获取配置中的 ticket
|
||||
self.ticket = self._config.get("jx3api_ticket", "")
|
||||
if self.ticket == "":
|
||||
logger.warning("获取配置ticket失败,请正确填写ticket,否则部分功能无法正常使用")
|
||||
else:
|
||||
logger.debug(f"获取配置ticket成功。{self.ticket}")
|
||||
logger.debug(f"获取配置ticket成功。")
|
||||
|
||||
|
||||
async def close(self):
|
||||
@@ -100,11 +114,13 @@ class JX3APIService:
|
||||
if self._api:
|
||||
await self._api.close()
|
||||
|
||||
async def server_list(self) -> list[str]:
|
||||
async def server_list(self, force_refresh: bool = False) -> list[str]:
|
||||
"""获取当前有效区服名称,供会话绑定和参数消歧使用。"""
|
||||
data = await self._base_request(
|
||||
data, _ = await self._cached_request(
|
||||
"/server/status/check",
|
||||
{"server": "", "type": "其他"},
|
||||
force_refresh=force_refresh,
|
||||
allow_stale=not force_refresh,
|
||||
)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
@@ -117,18 +133,38 @@ class JX3APIService:
|
||||
)
|
||||
|
||||
async def token_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""读取令牌统计;短时内存复用,避免 WebUI 保存配置时重复请求。"""
|
||||
if not str(self.token or "").strip():
|
||||
return None
|
||||
now = time.monotonic()
|
||||
if self._token_stats_cache and self._token_stats_cache[0] > now:
|
||||
return dict(self._token_stats_cache[1])
|
||||
|
||||
async with self._token_stats_lock:
|
||||
now = time.monotonic()
|
||||
if self._token_stats_cache and self._token_stats_cache[0] > now:
|
||||
return dict(self._token_stats_cache[1])
|
||||
result = await self._fetch_token_stats()
|
||||
if result is not None:
|
||||
self._token_stats_cache = (now + 30, dict(result))
|
||||
return result
|
||||
|
||||
async def _fetch_token_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""查询当前配置 JX3API Token 的等级、用量及有效状态。"""
|
||||
if not str(self.token or "").strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = await self._api.post(
|
||||
async def requester():
|
||||
return await self._api.post(
|
||||
"https://www.jx3api.com/token/stats",
|
||||
data={"token": self.token},
|
||||
out_key="data",
|
||||
success_codes=(200, "200"),
|
||||
return_error=True,
|
||||
)
|
||||
|
||||
try:
|
||||
data = await requester()
|
||||
except Exception as exc:
|
||||
logger.warning(f"查询 JX3API Token 统计失败: {exc}")
|
||||
return None
|
||||
@@ -204,6 +240,56 @@ class JX3APIService:
|
||||
logger.error(f"基础请求调用出错 ({api_path}): {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_cacheable_response(data: Any) -> bool:
|
||||
return data is not None and not isinstance(data, APIErrorResponse)
|
||||
|
||||
async def _cached_request(
|
||||
self,
|
||||
api_path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
out: Optional[str] = "data",
|
||||
force_refresh: bool = False,
|
||||
allow_stale: bool = True,
|
||||
) -> tuple[Any, dict[str, Any]]:
|
||||
request_params = params or {}
|
||||
if not self._cache:
|
||||
return await self._base_request(api_path, request_params, out), {
|
||||
"endpoint": api_path,
|
||||
"hit": False,
|
||||
"stale": False,
|
||||
"ttl_seconds": 0,
|
||||
}
|
||||
|
||||
cache_params = dict(request_params)
|
||||
credential_values = [
|
||||
str(value)
|
||||
for key, value in request_params.items()
|
||||
if str(key).lower() in {"token", "ticket"} and value
|
||||
]
|
||||
if credential_values:
|
||||
cache_params["__credential_scope"] = hashlib.sha256(
|
||||
"|".join(credential_values).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
try:
|
||||
return await self._cache.request_api(
|
||||
api_path,
|
||||
cache_params,
|
||||
lambda: self._base_request(api_path, request_params, out),
|
||||
self._is_cacheable_response,
|
||||
force_refresh=force_refresh,
|
||||
allow_stale=allow_stale,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"接口缓存不可用,直接请求 JX3API endpoint={api_path}: {exc}")
|
||||
return await self._base_request(api_path, request_params, out), {
|
||||
"endpoint": api_path,
|
||||
"hit": False,
|
||||
"stale": False,
|
||||
"ttl_seconds": 0,
|
||||
}
|
||||
|
||||
|
||||
async def _request_api(
|
||||
self,
|
||||
@@ -217,7 +303,8 @@ class JX3APIService:
|
||||
"""通用接口请求与模板处理。"""
|
||||
return_data = self._init_return_data()
|
||||
|
||||
data = await self._base_request(path, params)
|
||||
data, cache_metadata = await self._cached_request(path, params)
|
||||
return_data["_cache"] = cache_metadata
|
||||
if isinstance(data, APIErrorResponse):
|
||||
return_data["msg"] = data.message or "获取接口信息失败"
|
||||
return return_data
|
||||
|
||||
+330
-38
@@ -1,24 +1,48 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiocqhttp.exceptions import ActionFailed
|
||||
from astrbot.core import html_renderer
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
SessionController,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
from .bilei_data import BiLeidata
|
||||
from .cache import CacheService
|
||||
from .event_push import EventPushService
|
||||
from .jx3api_data import JX3APIService
|
||||
from .jx3box_data import JX3BOXService
|
||||
from .event_push import EventPushService
|
||||
from .bilei_data import BiLeidata
|
||||
|
||||
|
||||
class MessageBuilder:
|
||||
"""回复消息构建"""
|
||||
|
||||
IMAGE_RENDER_HANDLERS = frozenset(
|
||||
{
|
||||
"helps", "richangyuche", "qiongyewei", "pifenghui", "yunchongshe",
|
||||
"chutianshe", "guanaishouling", "zhenyingevent", "yanhuachaxun",
|
||||
"zhanji", "mingjianpaihang", "mingjiantongji", "kuafumingjian",
|
||||
"wulinzhengba", "bukairongyu", "jianghulangke", "juedoutiaozhan",
|
||||
"banghuipaihang", "zhenyingpaihang", "qitapaihang", "shilianpaixing",
|
||||
"zhengyingpaimai", "dilujilu", "jinjia", "wujia", "chengbeng",
|
||||
"bangzhanjilu", "shapan", "zhueevent", "qiyuhuizong", "weizuoqiyu",
|
||||
"jinqiqiyu", "juesheqiyu", "qiyutongji", "qiyugonglue", "jingnai",
|
||||
"baizhan", "chengjiu", "zilipaixing", "jineng", "qixue", "liaotian",
|
||||
"xiaoyao", "huajia", "zhuangshi", "qiwu", "baishi", "shoutu",
|
||||
"tuanduizhaomu", "tuanzhang", "tuanpai", "zhuangtai", "fubeng",
|
||||
"diaoluo", "hong", "zili", "jiaoyihang", "bilei_all", "bilei_select",
|
||||
}
|
||||
)
|
||||
|
||||
_RENDER_FORMATS = {"jpeg", "png"}
|
||||
_DATA_TIME_MARKER = "data-jx3-data-time"
|
||||
_DATA_TIME_ZONE = ZoneInfo("Asia/Shanghai")
|
||||
_SESSION_SCOPED_IMAGE_NAMES = frozenset({"避雷查看", "避雷查询"})
|
||||
_DEVICE_SCALE_FACTOR_LEVELS = {
|
||||
1.0: "normal",
|
||||
1.3: "high",
|
||||
@@ -31,6 +55,7 @@ class MessageBuilder:
|
||||
event_push: EventPushService,
|
||||
icons: dict[str, dict[str, str]],
|
||||
render_config: dict[str, Any] | None = None,
|
||||
cache: CacheService | None = None,
|
||||
):
|
||||
self.jx3api = jx3api
|
||||
self.jx3box = jx3box
|
||||
@@ -38,6 +63,7 @@ class MessageBuilder:
|
||||
self.event_push = event_push
|
||||
self.icons = icons
|
||||
self.render_config = render_config if isinstance(render_config, dict) else {}
|
||||
self.cache = cache
|
||||
|
||||
|
||||
def _build_render_options(
|
||||
@@ -94,7 +120,151 @@ class MessageBuilder:
|
||||
return_url=return_url,
|
||||
options=self._build_render_options(options),
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _format_data_time(result: dict[str, Any] | None = None) -> str:
|
||||
"""优先使用接口缓存创建时间,否则使用本次数据生成时间。"""
|
||||
cache_metadata = (result or {}).get("_cache") or {}
|
||||
timestamp = cache_metadata.get("created_at")
|
||||
try:
|
||||
numeric_timestamp = float(timestamp)
|
||||
if numeric_timestamp > 10_000_000_000:
|
||||
numeric_timestamp /= 1000
|
||||
if numeric_timestamp > 0:
|
||||
return datetime.fromtimestamp(
|
||||
numeric_timestamp,
|
||||
tz=MessageBuilder._DATA_TIME_ZONE,
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
pass
|
||||
return datetime.now(MessageBuilder._DATA_TIME_ZONE).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _ensure_data_time_footer(cls, template: str) -> str:
|
||||
"""为不使用公共布局的动态 HTML 补上统一的数据时间区域。"""
|
||||
if cls._DATA_TIME_MARKER in template:
|
||||
return template
|
||||
footer = """
|
||||
<style>
|
||||
.jx3-data-time {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
padding: 12px 24px 4px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.35);
|
||||
color: #64748b;
|
||||
font: 500 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
<footer class="jx3-data-time" data-jx3-data-time>
|
||||
数据时间:<time>{{ data_time }}</time>
|
||||
</footer>
|
||||
"""
|
||||
body_end = template.lower().rfind("</body>")
|
||||
if body_end >= 0:
|
||||
return f"{template[:body_end]}{footer}{template[body_end:]}"
|
||||
return f"{template}{footer}"
|
||||
|
||||
async def _render_image_file(
|
||||
self,
|
||||
template: str,
|
||||
render_data: dict[str, Any],
|
||||
cache_name: str = "",
|
||||
render_options: dict[str, Any] | None = None,
|
||||
include_icons: bool = False,
|
||||
source_signature: str = "",
|
||||
variant_signature: str = "",
|
||||
cache_key_override: str = "",
|
||||
cache_lock_held: bool = False,
|
||||
message_text: str = "",
|
||||
data_time: str = "",
|
||||
) -> str:
|
||||
"""命中时返回持久化图片,未命中时渲染一次并写入缓存。"""
|
||||
effective_cache_name = cache_name or (
|
||||
self.cache.current_command() if self.cache else ""
|
||||
)
|
||||
options = self._build_render_options(render_options)
|
||||
cache_key = cache_key_override
|
||||
if self.cache and effective_cache_name:
|
||||
if not cache_key:
|
||||
cache_key = self.cache.build_image_key(
|
||||
effective_cache_name,
|
||||
template,
|
||||
render_data,
|
||||
options,
|
||||
source_signature=source_signature,
|
||||
variant_signature=(
|
||||
variant_signature or self.cache.current_command_signature()
|
||||
),
|
||||
)
|
||||
cached_path = await self.cache.get_image(cache_key, effective_cache_name)
|
||||
if cached_path:
|
||||
return str(cached_path)
|
||||
|
||||
async def render_and_save() -> str:
|
||||
payload = dict(render_data)
|
||||
payload["data_time"] = data_time or self._format_data_time()
|
||||
if include_icons:
|
||||
payload["icons"] = self.icons
|
||||
rendered_path = await self.html_render(
|
||||
self._ensure_data_time_footer(template),
|
||||
payload,
|
||||
return_url=False,
|
||||
options=options,
|
||||
)
|
||||
if self.cache and cache_key:
|
||||
saved_path = await self.cache.save_image(
|
||||
cache_key,
|
||||
effective_cache_name,
|
||||
rendered_path,
|
||||
str(options.get("type") or "jpeg"),
|
||||
message_text=message_text,
|
||||
)
|
||||
if saved_path:
|
||||
return str(saved_path)
|
||||
return rendered_path
|
||||
|
||||
if not self.cache or not cache_key:
|
||||
return await render_and_save()
|
||||
if cache_lock_held:
|
||||
return await render_and_save()
|
||||
|
||||
async with self.cache.image_lock(cache_key):
|
||||
cached_path = await self.cache.get_image(cache_key, effective_cache_name)
|
||||
if cached_path:
|
||||
return str(cached_path)
|
||||
return await render_and_save()
|
||||
|
||||
def _image_request_identity(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
cache_name: str,
|
||||
render_options: dict[str, Any] | None,
|
||||
cache_variant: str,
|
||||
) -> tuple[str, str]:
|
||||
if not self.cache:
|
||||
return cache_name, ""
|
||||
effective_cache_name = cache_name or self.cache.current_command()
|
||||
if (
|
||||
not effective_cache_name
|
||||
or self.cache.get_ttl("image", effective_cache_name) <= 0
|
||||
):
|
||||
return effective_cache_name, ""
|
||||
variant_signature = cache_variant or self.cache.current_command_signature()
|
||||
scope_signature = ""
|
||||
if effective_cache_name in self._SESSION_SCOPED_IMAGE_NAMES:
|
||||
scope_signature = self.cache.value_signature(event.unified_msg_origin)
|
||||
return effective_cache_name, self.cache.build_image_request_key(
|
||||
effective_cache_name,
|
||||
self._build_render_options(render_options),
|
||||
variant_signature,
|
||||
scope_signature,
|
||||
)
|
||||
|
||||
|
||||
async def plain_msg(self, event: AstrMessageEvent, action):
|
||||
"""最终将数据整理成文本发送"""
|
||||
@@ -114,20 +284,62 @@ class MessageBuilder:
|
||||
event: AstrMessageEvent,
|
||||
action,
|
||||
render_options: dict | None = None,
|
||||
cache_name: str = "",
|
||||
cache_variant: str = "",
|
||||
):
|
||||
"""最终将数据渲染成图片发送"""
|
||||
data = await action()
|
||||
try:
|
||||
if data["code"] == 200:
|
||||
data["data"]["icons"] = self.icons
|
||||
url = await self.html_render(
|
||||
effective_cache_name, request_key = self._image_request_identity(
|
||||
event,
|
||||
cache_name,
|
||||
render_options,
|
||||
cache_variant,
|
||||
)
|
||||
data = None
|
||||
image_path = ""
|
||||
|
||||
async def request_and_render(cache_lock_held: bool = False):
|
||||
nonlocal data
|
||||
data = await action()
|
||||
if data["code"] != 200:
|
||||
return ""
|
||||
return await self._render_image_file(
|
||||
data["temp"],
|
||||
data["data"],
|
||||
options=render_options,
|
||||
cache_name=effective_cache_name,
|
||||
render_options=render_options,
|
||||
include_icons=True,
|
||||
source_signature=str(
|
||||
(data.get("_cache") or {}).get("data_hash") or ""
|
||||
),
|
||||
variant_signature=cache_variant,
|
||||
cache_key_override=request_key,
|
||||
cache_lock_held=cache_lock_held,
|
||||
data_time=self._format_data_time(data),
|
||||
)
|
||||
await event.send(event.image_result(url))
|
||||
|
||||
if self.cache and request_key:
|
||||
async with self.cache.image_lock(request_key):
|
||||
cached_path = await self.cache.get_image(
|
||||
request_key,
|
||||
effective_cache_name,
|
||||
)
|
||||
image_path = (
|
||||
str(cached_path)
|
||||
if cached_path
|
||||
else await request_and_render(cache_lock_held=True)
|
||||
)
|
||||
else:
|
||||
await event.send(event.plain_result(data["msg"]))
|
||||
image_path = await request_and_render()
|
||||
|
||||
if image_path:
|
||||
await event.send(event.image_result(image_path))
|
||||
else:
|
||||
await event.send(
|
||||
event.plain_result(
|
||||
(data or {}).get("msg") or "获取接口信息失败"
|
||||
)
|
||||
)
|
||||
|
||||
except ActionFailed as e:
|
||||
if e.retcode == 1200:
|
||||
@@ -170,22 +382,69 @@ class MessageBuilder:
|
||||
await event.send(event.plain_result("猪脑过载,请稍后再试"))
|
||||
|
||||
|
||||
async def plain_image_msg(self, event: AstrMessageEvent, action):
|
||||
async def plain_image_msg(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
action,
|
||||
cache_name: str = "",
|
||||
cache_variant: str = "",
|
||||
):
|
||||
"""发送正文文本,并把可选 HTML 正文渲染为附图。"""
|
||||
try:
|
||||
data = await action()
|
||||
if data.get("code") != 200:
|
||||
effective_cache_name, request_key = self._image_request_identity(
|
||||
event,
|
||||
cache_name,
|
||||
None,
|
||||
cache_variant,
|
||||
)
|
||||
data = None
|
||||
image_path = ""
|
||||
message_text = ""
|
||||
|
||||
async def request_and_render(cache_lock_held: bool = False):
|
||||
nonlocal data, message_text
|
||||
data = await action()
|
||||
if data.get("code") != 200:
|
||||
return ""
|
||||
message_text = str(data.get("data") or "")
|
||||
if not data.get("temp"):
|
||||
return ""
|
||||
return await self._render_image_file(
|
||||
data["temp"],
|
||||
{},
|
||||
cache_name=effective_cache_name,
|
||||
variant_signature=cache_variant,
|
||||
cache_key_override=request_key,
|
||||
cache_lock_held=cache_lock_held,
|
||||
message_text=message_text,
|
||||
data_time=self._format_data_time(data),
|
||||
)
|
||||
|
||||
if self.cache and request_key:
|
||||
async with self.cache.image_lock(request_key):
|
||||
cached_entry = await self.cache.get_image_entry(
|
||||
request_key,
|
||||
effective_cache_name,
|
||||
)
|
||||
if cached_entry:
|
||||
image_path = str(cached_entry[0])
|
||||
message_text = cached_entry[1]
|
||||
else:
|
||||
image_path = await request_and_render(cache_lock_held=True)
|
||||
else:
|
||||
image_path = await request_and_render()
|
||||
|
||||
if data is not None and data.get("code") != 200:
|
||||
await event.send(
|
||||
event.plain_result(data.get("msg") or "获取详细数据失败")
|
||||
)
|
||||
return
|
||||
|
||||
chain = MessageChain()
|
||||
if data.get("data"):
|
||||
chain.message(str(data["data"]))
|
||||
if data.get("temp"):
|
||||
url = await self.html_render(data["temp"], {})
|
||||
chain.url_image(url)
|
||||
if message_text:
|
||||
chain.message(message_text)
|
||||
if image_path:
|
||||
chain.file_image(image_path)
|
||||
await event.send(chain)
|
||||
except Exception as e:
|
||||
logger.error(f"功能函数执行错误: {e}")
|
||||
@@ -213,14 +472,25 @@ class MessageBuilder:
|
||||
await event.send(event.plain_result("\n".join(menu_lines)))
|
||||
user_id = event.get_sender_id()
|
||||
send_result = result_handler or self.T2I_image_msg
|
||||
cache_name = self.cache.current_command() if self.cache else ""
|
||||
cache_variant = (
|
||||
self.cache.current_command_signature() if self.cache else ""
|
||||
)
|
||||
|
||||
async def send_selected(
|
||||
target_event: AstrMessageEvent,
|
||||
selected: dict[str, Any],
|
||||
):
|
||||
selected_variant = cache_variant
|
||||
if self.cache:
|
||||
selected_variant = (
|
||||
f"{cache_variant}:{self.cache.value_signature(selected)}"
|
||||
)
|
||||
await send_result(
|
||||
target_event,
|
||||
lambda: action2(selected),
|
||||
cache_name=cache_name,
|
||||
cache_variant=selected_variant,
|
||||
)
|
||||
|
||||
@session_waiter(timeout=timeout)
|
||||
@@ -323,8 +593,7 @@ class MessageBuilder:
|
||||
|
||||
async def yanhuachaxun(self, event: AstrMessageEvent, server: str, name: str = "", limit: int = 50):
|
||||
""" 烟花 服务器 角色 条数"""
|
||||
if limit <= 0:
|
||||
return event.plain_result("条数必须为正整数")
|
||||
limit = limit if limit > 0 else 50
|
||||
return await self.T2I_image_msg(
|
||||
event, lambda: self.jx3api.yanhuachaxun(server, name, limit)
|
||||
)
|
||||
@@ -349,22 +618,20 @@ class MessageBuilder:
|
||||
""" 名剑统计 模式"""
|
||||
return await self.T2I_image_msg(event, lambda: self.jx3api.mingjiantongji(mode))
|
||||
|
||||
async def kuafumingjian(self, event: AstrMessageEvent, server: str, mode: int = 1,):
|
||||
async def kuafumingjian(self, event: AstrMessageEvent, server: str, mode: int = 33):
|
||||
"""跨服名剑 服务器 [模式]。"""
|
||||
if mode not in {0, 1, 2}:
|
||||
return event.plain_result("竞技模式仅支持 0=2v2、1=3v3、2=5v5")
|
||||
api_mode = {22: 0, 33: 1, 55: 2}.get(mode, 1)
|
||||
return await self.T2I_image_msg(
|
||||
event,
|
||||
lambda: self.jx3api.kuafumingjian(server, mode),
|
||||
lambda: self.jx3api.kuafumingjian(server, api_mode),
|
||||
)
|
||||
|
||||
async def wulinzhengba(self,event: AstrMessageEvent,server: str,camp: int = 1,):
|
||||
async def wulinzhengba(self,event: AstrMessageEvent,server: str,camp: str = "浩气盟",):
|
||||
"""武林争霸 服务器 [阵营]。"""
|
||||
if camp not in {1, 2}:
|
||||
return event.plain_result("阵营仅支持 1=浩气、2=恶人")
|
||||
api_camp = {"浩气盟": 1, "恶人谷": 2}.get(camp, 1)
|
||||
return await self.T2I_image_msg(
|
||||
event,
|
||||
lambda: self.jx3api.wulinzhengba(server, camp),
|
||||
lambda: self.jx3api.wulinzhengba(server, api_camp),
|
||||
)
|
||||
|
||||
async def bukairongyu(self, event: AstrMessageEvent, server: str):
|
||||
@@ -381,13 +648,12 @@ class MessageBuilder:
|
||||
lambda: self.jx3api.jianghulangke(server),
|
||||
)
|
||||
|
||||
async def juedoutiaozhan(self,event: AstrMessageEvent,server: str,mode: int = 1,):
|
||||
async def juedoutiaozhan(self,event: AstrMessageEvent,server: str,mode: int = "公开",):
|
||||
"""决斗挑战 服务器 [模式]。"""
|
||||
if mode not in {1, 2}:
|
||||
return event.plain_result("模式仅支持 1=公开、2=私密")
|
||||
api_mode = {"公开": 1, "私密": 2}.get(mode, 1)
|
||||
return await self.T2I_image_msg(
|
||||
event,
|
||||
lambda: self.jx3api.juedoutiaozhan(server, mode),
|
||||
lambda: self.jx3api.juedoutiaozhan(server, api_mode),
|
||||
)
|
||||
|
||||
async def banghuipaihang(self, event: AstrMessageEvent, server: str):
|
||||
@@ -687,23 +953,49 @@ class MessageBuilder:
|
||||
|
||||
async def bilei_add(self, event: AstrMessageEvent,name: str, text: str):
|
||||
"""避雷添加 名称 备注"""
|
||||
return await self.plain_msg(event, lambda: self.bilei.add(name,text,event.get_sender_name()))
|
||||
return await self.plain_msg(
|
||||
event,
|
||||
lambda: self.bilei.add(
|
||||
event.unified_msg_origin,
|
||||
name,
|
||||
text,
|
||||
event.get_sender_name(),
|
||||
),
|
||||
)
|
||||
|
||||
async def bilei_all(self, event: AstrMessageEvent):
|
||||
"""避雷查看"""
|
||||
return await self.T2I_image_msg(event, self.bilei.all)
|
||||
return await self.T2I_image_msg(
|
||||
event,
|
||||
lambda: self.bilei.all(event.unified_msg_origin),
|
||||
)
|
||||
|
||||
async def bilei_select(self, event: AstrMessageEvent, name:str):
|
||||
"""避雷查询"""
|
||||
return await self.T2I_image_msg(event, lambda: self.bilei.select(name))
|
||||
return await self.T2I_image_msg(
|
||||
event,
|
||||
lambda: self.bilei.select(event.unified_msg_origin, name),
|
||||
)
|
||||
|
||||
async def bilei_update(self, event: AstrMessageEvent, id:int, name: str, text: str):
|
||||
"""避雷修改 ID 名称 备注"""
|
||||
return await self.plain_msg(event, lambda: self.bilei.update(id,name,text,event.get_sender_name()))
|
||||
return await self.plain_msg(
|
||||
event,
|
||||
lambda: self.bilei.update(
|
||||
event.unified_msg_origin,
|
||||
id,
|
||||
name,
|
||||
text,
|
||||
event.get_sender_name(),
|
||||
),
|
||||
)
|
||||
|
||||
async def bilei_delete(self, event: AstrMessageEvent, id:int):
|
||||
"""避雷删除 ID"""
|
||||
return await self.plain_msg(event, lambda: self.bilei.delete(id))
|
||||
return await self.plain_msg(
|
||||
event,
|
||||
lambda: self.bilei.delete(event.unified_msg_origin, id),
|
||||
)
|
||||
|
||||
|
||||
async def shijian_tuisong(
|
||||
|
||||
@@ -37,6 +37,12 @@ class AsyncSQLiteDB:
|
||||
async with self.conn.execute(sql, params):
|
||||
await self.conn.commit()
|
||||
|
||||
async def execute_affected(self, sql: str, params: Tuple = ()) -> int:
|
||||
"""执行写入语句并返回受影响的行数。"""
|
||||
async with self.conn.execute(sql, params) as cursor:
|
||||
await self.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def execute_transaction(
|
||||
self,
|
||||
statements: List[Tuple[str, Tuple[Any, ...]]],
|
||||
|
||||
+77
-1
@@ -10,6 +10,8 @@ from astrbot.api.web import error_response, json_response, request
|
||||
from .event_push import EVENT_NAMES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .bilei_data import BiLeidata
|
||||
from .cache import CacheService
|
||||
from .event_push import EventPushService
|
||||
from .jx3api_data import JX3APIService
|
||||
from .kungfu_alias import KungfuAliasService
|
||||
@@ -27,12 +29,16 @@ class WebUIService:
|
||||
server_binding: ServerBindingService,
|
||||
kungfu_alias: KungfuAliasService,
|
||||
session_control: SessionControlService,
|
||||
bilei: BiLeidata,
|
||||
cache: CacheService,
|
||||
):
|
||||
self.jx3api = jx3api
|
||||
self.event_push = event_push
|
||||
self.server_binding = server_binding
|
||||
self.kungfu_alias = kungfu_alias
|
||||
self.session_control = session_control
|
||||
self.bilei = bilei
|
||||
self.cache = cache
|
||||
|
||||
def register(self, context: Context, plugin_name: str):
|
||||
routes = (
|
||||
@@ -63,6 +69,16 @@ class WebUIService:
|
||||
["POST"],
|
||||
"删除会话控制名单",
|
||||
),
|
||||
(
|
||||
"bilei/legacy/migrate",
|
||||
self.migrate_legacy_bilei,
|
||||
["POST"],
|
||||
"迁移旧避雷记录到指定会话",
|
||||
),
|
||||
("cache/settings/save", self.save_cache_setting, ["POST"], "保存缓存时间"),
|
||||
("cache/limits/save", self.save_cache_limits, ["POST"], "保存缓存容量限制"),
|
||||
("cache/item/clear", self.clear_cache_item, ["POST"], "清理单项缓存"),
|
||||
("cache/clear", self.clear_cache, ["POST"], "清理查询缓存"),
|
||||
)
|
||||
for path, handler, methods, description in routes:
|
||||
context.register_web_api(
|
||||
@@ -94,14 +110,18 @@ class WebUIService:
|
||||
aliases,
|
||||
kungfu,
|
||||
session_control,
|
||||
legacy_bilei,
|
||||
token_stats,
|
||||
cache,
|
||||
) = await asyncio.gather(
|
||||
self.server_binding.list_bindings(),
|
||||
self.event_push.list_subscription_statuses(),
|
||||
self.server_binding.list_aliases(),
|
||||
self.kungfu_alias.list_kungfu(),
|
||||
self.session_control.get_state(),
|
||||
self.bilei.list_legacy_records(),
|
||||
self.jx3api.token_stats(),
|
||||
self.cache.dashboard(),
|
||||
)
|
||||
return json_response(
|
||||
{
|
||||
@@ -114,7 +134,9 @@ class WebUIService:
|
||||
str(action): name for action, name in EVENT_NAMES.items()
|
||||
},
|
||||
"session_control": session_control,
|
||||
"legacy_bilei": legacy_bilei,
|
||||
"token_stats": token_stats,
|
||||
"cache": cache,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -193,7 +215,7 @@ class WebUIService:
|
||||
return json_response({"restored": restored})
|
||||
|
||||
async def refresh_servers(self):
|
||||
servers = await self.jx3api.server_list()
|
||||
servers = await self.jx3api.server_list(force_refresh=True)
|
||||
if not servers:
|
||||
return error_response("区服目录刷新失败", status_code=502)
|
||||
await self.server_binding.update_server_catalog(servers)
|
||||
@@ -226,3 +248,57 @@ class WebUIService:
|
||||
except ValueError as exc:
|
||||
return error_response(str(exc), status_code=400)
|
||||
return json_response({"deleted": True})
|
||||
|
||||
async def migrate_legacy_bilei(self):
|
||||
try:
|
||||
payload = await self._json_payload()
|
||||
await self.bilei.migrate_legacy_record(
|
||||
payload.get("id"),
|
||||
payload.get("session_id"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return error_response(str(exc), status_code=400)
|
||||
return json_response({"migrated": True})
|
||||
|
||||
async def save_cache_setting(self):
|
||||
try:
|
||||
payload = await self._json_payload()
|
||||
await self.cache.set_ttl(
|
||||
str(payload.get("cache_type") or ""),
|
||||
str(payload.get("cache_name") or ""),
|
||||
payload.get("ttl_seconds"),
|
||||
payload.get("inherit") is True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return error_response(str(exc), status_code=400)
|
||||
return json_response({"saved": True})
|
||||
|
||||
async def save_cache_limits(self):
|
||||
try:
|
||||
payload = await self._json_payload()
|
||||
await self.cache.set_limits(
|
||||
payload.get("api_memory_entries"),
|
||||
payload.get("image_max_mb"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return error_response(str(exc), status_code=400)
|
||||
return json_response({"saved": True})
|
||||
|
||||
async def clear_cache_item(self):
|
||||
try:
|
||||
payload = await self._json_payload()
|
||||
removed = await self.cache.clear_item(
|
||||
str(payload.get("cache_type") or ""),
|
||||
str(payload.get("cache_name") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return error_response(str(exc), status_code=400)
|
||||
return json_response({"cleared": True, "removed": removed})
|
||||
|
||||
async def clear_cache(self):
|
||||
try:
|
||||
payload = await self._json_payload()
|
||||
removed = await self.cache.clear(str(payload.get("cache_type") or ""))
|
||||
except ValueError as exc:
|
||||
return error_response(str(exc), status_code=400)
|
||||
return json_response({"cleared": True, "removed": removed})
|
||||
|
||||
Reference in New Issue
Block a user