This commit is contained in:
qsc
2026-09-06 19:22:56 +08:00
parent a42d3146c5
commit 2cf54a5e6c
18 changed files with 2203 additions and 216 deletions
+176 -25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+6
View File
@@ -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
View File
@@ -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})