chore: import private baseline from gitea state
This commit is contained in:
19
plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py
Normal file
19
plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""NapCat 内部服务导出。"""
|
||||
|
||||
from .action_service import NapCatActionService
|
||||
from .ban_tracker import NapCatBanTracker
|
||||
from .ban_state_store import NapCatBanRecord, NapCatBanStateStore
|
||||
from .history_recovery_store import NapCatChatCheckpoint, NapCatHistoryRecoveryStore
|
||||
from .official_bot_guard import NapCatOfficialBotGuard
|
||||
from .query_service import NapCatQueryService
|
||||
|
||||
__all__ = [
|
||||
"NapCatActionService",
|
||||
"NapCatBanRecord",
|
||||
"NapCatBanStateStore",
|
||||
"NapCatBanTracker",
|
||||
"NapCatChatCheckpoint",
|
||||
"NapCatHistoryRecoveryStore",
|
||||
"NapCatOfficialBotGuard",
|
||||
"NapCatQueryService",
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""NapCat 底层动作调用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional
|
||||
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
ClientSession = None # type: ignore[assignment]
|
||||
ClientTimeout = None # type: ignore[assignment]
|
||||
AIOHTTP_AVAILABLE = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..transport import NapCatTransportClient
|
||||
|
||||
|
||||
class NapCatActionService:
|
||||
"""NapCat 底层动作与资源访问服务。"""
|
||||
|
||||
def __init__(self, logger: Any, transport: "NapCatTransportClient") -> None:
|
||||
"""初始化底层动作服务。
|
||||
|
||||
Args:
|
||||
logger: 插件日志对象。
|
||||
transport: NapCat 传输层客户端。
|
||||
"""
|
||||
self._logger = logger
|
||||
self._transport = transport
|
||||
|
||||
async def call_action(self, action_name: str, params: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
"""调用 OneBot 动作并要求返回成功结果。
|
||||
|
||||
Args:
|
||||
action_name: OneBot 动作名称。
|
||||
params: 动作参数。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: NapCat 返回的原始响应字典。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 当动作执行失败或平台返回非成功状态时抛出。
|
||||
"""
|
||||
normalized_params = {str(key): value for key, value in params.items()}
|
||||
try:
|
||||
response = await self._transport.call_action(action_name, normalized_params)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"NapCat 动作执行失败: action={action_name} error={exc}") from exc
|
||||
|
||||
if str(response.get("status") or "").lower() != "ok":
|
||||
error_message = str(response.get("wording") or response.get("message") or "unknown")
|
||||
raise RuntimeError(f"NapCat 动作返回失败: action={action_name} message={error_message}")
|
||||
return response
|
||||
|
||||
async def call_action_data(self, action_name: str, params: Mapping[str, Any]) -> Any:
|
||||
"""调用 OneBot 动作并返回 ``data`` 字段。
|
||||
|
||||
Args:
|
||||
action_name: OneBot 动作名称。
|
||||
params: 动作参数。
|
||||
|
||||
Returns:
|
||||
Any: NapCat 响应中的 ``data`` 字段。
|
||||
"""
|
||||
response = await self.call_action(action_name, params)
|
||||
return response.get("data")
|
||||
|
||||
async def safe_call_action_data(self, action_name: str, params: Mapping[str, Any]) -> Any:
|
||||
"""安全调用 OneBot 动作并返回 ``data`` 字段。
|
||||
|
||||
Args:
|
||||
action_name: OneBot 动作名称。
|
||||
params: 动作参数。
|
||||
|
||||
Returns:
|
||||
Any: 响应中的 ``data`` 字段;失败时返回 ``None``。
|
||||
"""
|
||||
try:
|
||||
return await self.call_action_data(action_name, params)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"NapCat 查询动作执行失败: action={action_name} error={exc}")
|
||||
return None
|
||||
|
||||
async def download_binary(self, url: str) -> Optional[bytes]:
|
||||
"""下载远程二进制资源。
|
||||
|
||||
Args:
|
||||
url: 资源 URL。
|
||||
|
||||
Returns:
|
||||
Optional[bytes]: 下载到的二进制内容;失败时返回 ``None``。
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
if not AIOHTTP_AVAILABLE or ClientSession is None or ClientTimeout is None:
|
||||
self._logger.warning("NapCat 查询层缺少 aiohttp,无法下载远程资源")
|
||||
return None
|
||||
|
||||
try:
|
||||
timeout = ClientTimeout(total=15)
|
||||
async with ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status != 200:
|
||||
self._logger.warning(f"NapCat 远程资源下载失败: status={response.status} url={url}")
|
||||
return None
|
||||
return await response.read()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"NapCat 远程资源下载失败: {exc}")
|
||||
return None
|
||||
@@ -0,0 +1,168 @@
|
||||
"""NapCat 禁言状态存储。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
_DEFAULT_STORAGE_PATH = _PROJECT_ROOT / "data" / "napcat_adapter" / "ban_state.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NapCatBanRecord:
|
||||
"""NapCat 禁言记录。"""
|
||||
|
||||
group_id: str
|
||||
user_id: str
|
||||
lift_time: int
|
||||
|
||||
@property
|
||||
def record_key(self) -> str:
|
||||
"""返回当前记录的稳定键。
|
||||
|
||||
Returns:
|
||||
str: 由群号和用户号拼接得到的稳定键。
|
||||
"""
|
||||
return f"{self.group_id}:{self.user_id}"
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, payload: Mapping[str, Any]) -> Optional["NapCatBanRecord"]:
|
||||
"""从字典构造禁言记录。
|
||||
|
||||
Args:
|
||||
payload: 原始记录字典。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatBanRecord]: 构造成功时返回记录对象,否则返回 ``None``。
|
||||
"""
|
||||
group_id = str(payload.get("group_id") or "").strip()
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
if not group_id or not user_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
lift_time = int(payload.get("lift_time", -1))
|
||||
except (TypeError, ValueError):
|
||||
lift_time = -1
|
||||
return cls(group_id=group_id, user_id=user_id, lift_time=lift_time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""将记录转换为可序列化字典。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 可直接写入 JSON 的记录字典。
|
||||
"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class NapCatBanStateStore:
|
||||
"""NapCat 禁言状态持久化仓库。"""
|
||||
|
||||
def __init__(self, logger: Any, storage_path: Path = _DEFAULT_STORAGE_PATH) -> None:
|
||||
"""初始化禁言状态仓库。
|
||||
|
||||
Args:
|
||||
logger: 插件日志对象。
|
||||
storage_path: 持久化文件路径。
|
||||
"""
|
||||
self._logger = logger
|
||||
self._storage_path = storage_path
|
||||
self._records: Dict[str, NapCatBanRecord] = {}
|
||||
self._records_lock = asyncio.Lock()
|
||||
|
||||
async def load(self) -> None:
|
||||
"""从本地文件加载禁言记录。"""
|
||||
if not self._storage_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
raw_payload = json.loads(self._storage_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"NapCat 禁言状态文件读取失败,将忽略旧记录: {exc}")
|
||||
return
|
||||
|
||||
if not isinstance(raw_payload, list):
|
||||
self._logger.warning("NapCat 禁言状态文件格式非法,将忽略旧记录")
|
||||
return
|
||||
|
||||
loaded_records: Dict[str, NapCatBanRecord] = {}
|
||||
for item in raw_payload:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
record = NapCatBanRecord.from_mapping(item)
|
||||
if record is not None:
|
||||
loaded_records[record.record_key] = record
|
||||
|
||||
async with self._records_lock:
|
||||
self._records = loaded_records
|
||||
if loaded_records:
|
||||
self._logger.info(f"NapCat 禁言状态已加载 {len(loaded_records)} 条记录")
|
||||
|
||||
async def snapshot(self) -> List[NapCatBanRecord]:
|
||||
"""返回当前记录快照。
|
||||
|
||||
Returns:
|
||||
List[NapCatBanRecord]: 当前内存中的记录列表副本。
|
||||
"""
|
||||
async with self._records_lock:
|
||||
return list(self._records.values())
|
||||
|
||||
async def upsert(self, record: NapCatBanRecord) -> None:
|
||||
"""新增或更新一条禁言记录。
|
||||
|
||||
Args:
|
||||
record: 待写入的禁言记录。
|
||||
"""
|
||||
async with self._records_lock:
|
||||
self._records[record.record_key] = record
|
||||
await self.persist()
|
||||
|
||||
async def remove(self, group_id: str, user_id: str) -> Optional[NapCatBanRecord]:
|
||||
"""删除指定禁言记录。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
user_id: 用户号。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatBanRecord]: 被移除的记录;不存在时返回 ``None``。
|
||||
"""
|
||||
record_key = f"{group_id}:{user_id}"
|
||||
return await self.pop(record_key)
|
||||
|
||||
async def pop(self, record_key: str) -> Optional[NapCatBanRecord]:
|
||||
"""按稳定键移除一条记录。
|
||||
|
||||
Args:
|
||||
record_key: 记录稳定键。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatBanRecord]: 被移除的记录;不存在时返回 ``None``。
|
||||
"""
|
||||
async with self._records_lock:
|
||||
removed_record = self._records.pop(record_key, None)
|
||||
if removed_record is not None:
|
||||
await self.persist()
|
||||
return removed_record
|
||||
|
||||
async def persist(self) -> None:
|
||||
"""将当前禁言记录持久化到本地文件。"""
|
||||
async with self._records_lock:
|
||||
serialized_records = [
|
||||
record.to_dict() for record in sorted(self._records.values(), key=lambda item: item.record_key)
|
||||
]
|
||||
|
||||
try:
|
||||
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._storage_path.write_text(
|
||||
json.dumps(serialized_records, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"NapCat 禁言状态持久化失败: {exc}")
|
||||
176
plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py
Normal file
176
plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""NapCat 群禁言状态跟踪服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
|
||||
from .ban_state_store import NapCatBanRecord, NapCatBanStateStore
|
||||
from .query_service import NapCatQueryService
|
||||
|
||||
|
||||
class NapCatBanTracker:
|
||||
"""NapCat 群禁言状态跟踪器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger: Any,
|
||||
query_service: NapCatQueryService,
|
||||
on_natural_lift: Callable[[Dict[str, Any]], Awaitable[None]],
|
||||
state_store: NapCatBanStateStore,
|
||||
) -> None:
|
||||
"""初始化群禁言状态跟踪器。
|
||||
|
||||
Args:
|
||||
logger: 插件日志对象。
|
||||
query_service: NapCat 查询服务。
|
||||
on_natural_lift: 检测到自然解除禁言后的回调。
|
||||
state_store: 禁言状态存储仓库。
|
||||
"""
|
||||
self._logger = logger
|
||||
self._query_service = query_service
|
||||
self._on_natural_lift = on_natural_lift
|
||||
self._state_store = state_store
|
||||
self._poll_task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动禁言状态跟踪。"""
|
||||
await self._state_store.load()
|
||||
await self._refresh_records_from_remote()
|
||||
if self._poll_task is None or self._poll_task.done():
|
||||
self._poll_task = asyncio.create_task(self._poll_loop(), name="napcat_adapter.ban_tracker")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止禁言状态跟踪并落盘当前记录。"""
|
||||
poll_task = self._poll_task
|
||||
self._poll_task = None
|
||||
if poll_task is not None:
|
||||
poll_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await poll_task
|
||||
await self._state_store.persist()
|
||||
|
||||
async def record_notice(self, payload: Mapping[str, Any]) -> None:
|
||||
"""根据实际 notice 事件更新禁言状态。
|
||||
|
||||
Args:
|
||||
payload: NapCat 推送的原始通知事件。
|
||||
"""
|
||||
notice_type = str(payload.get("notice_type") or "").strip()
|
||||
if notice_type != "group_ban":
|
||||
return
|
||||
|
||||
sub_type = str(payload.get("sub_type") or "").strip()
|
||||
group_id = str(payload.get("group_id") or "").strip()
|
||||
user_id = str(payload.get("user_id") or "0").strip() or "0"
|
||||
if not group_id:
|
||||
return
|
||||
|
||||
if sub_type == "ban":
|
||||
duration = self._normalize_int(payload.get("duration"), default=-1)
|
||||
lift_time = -1 if user_id == "0" or duration <= 0 else int(time.time()) + duration
|
||||
await self._state_store.upsert(NapCatBanRecord(group_id=group_id, user_id=user_id, lift_time=lift_time))
|
||||
return
|
||||
|
||||
if sub_type in {"lift_ban", "whole_lift_ban"}:
|
||||
await self._state_store.remove(group_id=group_id, user_id=user_id)
|
||||
|
||||
async def _refresh_records_from_remote(self) -> None:
|
||||
"""基于当前 QQ 平台状态校正本地禁言记录。"""
|
||||
for record in await self._state_store.snapshot():
|
||||
if record.user_id == "0":
|
||||
await self._refresh_whole_ban_record(record)
|
||||
continue
|
||||
await self._refresh_member_ban_record(record)
|
||||
|
||||
async def _refresh_whole_ban_record(self, record: NapCatBanRecord) -> None:
|
||||
"""刷新全体禁言记录。
|
||||
|
||||
Args:
|
||||
record: 待刷新的禁言记录。
|
||||
"""
|
||||
group_info = await self._query_service.get_group_info(record.group_id)
|
||||
if group_info is None:
|
||||
await self._emit_natural_lift(record)
|
||||
return
|
||||
|
||||
group_all_shut = self._normalize_int(group_info.get("group_all_shut"), default=0)
|
||||
if group_all_shut == 0:
|
||||
await self._emit_natural_lift(record)
|
||||
|
||||
async def _refresh_member_ban_record(self, record: NapCatBanRecord) -> None:
|
||||
"""刷新成员禁言记录。
|
||||
|
||||
Args:
|
||||
record: 待刷新的禁言记录。
|
||||
"""
|
||||
member_info = await self._query_service.get_group_member_info(record.group_id, record.user_id, no_cache=True)
|
||||
if member_info is None:
|
||||
await self._emit_natural_lift(record)
|
||||
return
|
||||
|
||||
shut_up_timestamp = self._normalize_int(member_info.get("shut_up_timestamp"), default=0)
|
||||
if shut_up_timestamp == 0:
|
||||
await self._emit_natural_lift(record)
|
||||
return
|
||||
|
||||
if shut_up_timestamp != record.lift_time:
|
||||
await self._state_store.upsert(
|
||||
NapCatBanRecord(group_id=record.group_id, user_id=record.user_id, lift_time=shut_up_timestamp)
|
||||
)
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
"""后台轮询自然解除禁言。"""
|
||||
while True:
|
||||
await asyncio.sleep(5.0)
|
||||
current_timestamp = int(time.time())
|
||||
for record in await self._state_store.snapshot():
|
||||
if record.user_id == "0":
|
||||
await self._refresh_whole_ban_record(record)
|
||||
continue
|
||||
if record.lift_time != -1 and record.lift_time <= current_timestamp:
|
||||
await self._emit_natural_lift(record)
|
||||
|
||||
async def _emit_natural_lift(self, record: NapCatBanRecord) -> None:
|
||||
"""上报自然解除禁言事件。
|
||||
|
||||
Args:
|
||||
record: 已解除的禁言记录。
|
||||
"""
|
||||
removed_record = await self._state_store.pop(record.record_key)
|
||||
if removed_record is None:
|
||||
return
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"post_type": "notice",
|
||||
"notice_type": "group_ban",
|
||||
"sub_type": "whole_lift_ban" if record.user_id == "0" else "lift_ban",
|
||||
"group_id": record.group_id,
|
||||
"user_id": record.user_id,
|
||||
"operator_id": None,
|
||||
"time": time.time(),
|
||||
"is_natural_lift": True,
|
||||
}
|
||||
try:
|
||||
await self._on_natural_lift(payload)
|
||||
except Exception as exc:
|
||||
self._logger.warning(f"NapCat 自然解除禁言回调失败: {exc}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_int(value: Any, default: int) -> int:
|
||||
"""将任意值规范化为整数。
|
||||
|
||||
Args:
|
||||
value: 待规范化的值。
|
||||
default: 转换失败时的默认值。
|
||||
|
||||
Returns:
|
||||
int: 规范化后的整数结果。
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
@@ -0,0 +1,423 @@
|
||||
"""NapCat 历史补拉状态持久化仓库。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, TypeVar
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from ..constants import DEFAULT_HISTORY_RECOVERY_SEEN_TTL_SEC
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
_DEFAULT_STORAGE_PATH = _PROJECT_ROOT / "data" / "napcat_adapter" / "history_recovery.sqlite3"
|
||||
|
||||
_SCHEMA_STATEMENTS = (
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS napcat_chat_checkpoint (
|
||||
account_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
chat_type TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
last_message_id TEXT NOT NULL,
|
||||
last_message_time REAL NOT NULL,
|
||||
last_message_seq INTEGER,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (account_id, scope, chat_type, chat_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_napcat_chat_checkpoint_updated_at
|
||||
ON napcat_chat_checkpoint (updated_at DESC)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS napcat_recovery_seen (
|
||||
account_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
chat_type TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
external_message_id TEXT NOT NULL,
|
||||
seen_at REAL NOT NULL,
|
||||
PRIMARY KEY (account_id, scope, chat_type, chat_id, external_message_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_napcat_recovery_seen_seen_at
|
||||
ON napcat_recovery_seen (seen_at DESC)
|
||||
""",
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NapCatChatCheckpoint:
|
||||
"""描述一个会话的最近入站锚点。"""
|
||||
|
||||
account_id: str
|
||||
scope: str
|
||||
chat_type: str
|
||||
chat_id: str
|
||||
last_message_id: str
|
||||
last_message_time: float
|
||||
last_message_seq: int | None
|
||||
updated_at: float
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: sqlite3.Row) -> "NapCatChatCheckpoint":
|
||||
"""从 SQLite 行对象恢复 checkpoint。"""
|
||||
|
||||
last_message_seq = row["last_message_seq"]
|
||||
normalized_seq = int(last_message_seq) if isinstance(last_message_seq, int) else None
|
||||
return cls(
|
||||
account_id=str(row["account_id"] or "").strip(),
|
||||
scope=str(row["scope"] or "").strip(),
|
||||
chat_type=str(row["chat_type"] or "").strip(),
|
||||
chat_id=str(row["chat_id"] or "").strip(),
|
||||
last_message_id=str(row["last_message_id"] or "").strip(),
|
||||
last_message_time=float(row["last_message_time"] or 0.0),
|
||||
last_message_seq=normalized_seq,
|
||||
updated_at=float(row["updated_at"] or 0.0),
|
||||
)
|
||||
|
||||
|
||||
class NapCatHistoryRecoveryStore:
|
||||
"""负责持久化历史补拉所需的会话状态与去重状态。"""
|
||||
|
||||
def __init__(self, logger: Any, storage_path: Path = _DEFAULT_STORAGE_PATH) -> None:
|
||||
"""初始化历史补拉状态仓库。"""
|
||||
|
||||
self._logger = logger
|
||||
self._storage_path = storage_path
|
||||
self._store_lock = asyncio.Lock()
|
||||
self._schema_ready = False
|
||||
|
||||
async def load(self) -> None:
|
||||
"""初始化 SQLite 文件并清理过期去重记录。"""
|
||||
|
||||
await self._execute_locked(self._ensure_schema)
|
||||
pruned_count = await self.prune_recovery_seen(DEFAULT_HISTORY_RECOVERY_SEEN_TTL_SEC)
|
||||
if pruned_count > 0:
|
||||
self._logger.debug(f"NapCat 历史补拉去重表已清理 {pruned_count} 条过期记录")
|
||||
|
||||
async def list_checkpoints(self, account_id: str, scope: str = "", limit: int = 50) -> List[NapCatChatCheckpoint]:
|
||||
"""列出指定账号与作用域下的最近会话 checkpoint。"""
|
||||
|
||||
normalized_account_id = str(account_id or "").strip()
|
||||
if not normalized_account_id:
|
||||
return []
|
||||
|
||||
normalized_scope = self._normalize_scope(scope)
|
||||
normalized_limit = max(1, int(limit))
|
||||
|
||||
def _operation(conn: sqlite3.Connection) -> List[NapCatChatCheckpoint]:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
account_id,
|
||||
scope,
|
||||
chat_type,
|
||||
chat_id,
|
||||
last_message_id,
|
||||
last_message_time,
|
||||
last_message_seq,
|
||||
updated_at
|
||||
FROM napcat_chat_checkpoint
|
||||
WHERE account_id = ? AND scope = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(normalized_account_id, normalized_scope, normalized_limit),
|
||||
)
|
||||
return [NapCatChatCheckpoint.from_row(row) for row in cursor.fetchall()]
|
||||
|
||||
return await self._execute_locked(_operation)
|
||||
|
||||
async def record_checkpoint(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
scope: str = "",
|
||||
chat_type: str,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
message_time: float,
|
||||
message_seq: int | None = None,
|
||||
) -> None:
|
||||
"""记录一条已被 Host 接受的最新入站消息锚点。"""
|
||||
|
||||
normalized_account_id = str(account_id or "").strip()
|
||||
normalized_scope = self._normalize_scope(scope)
|
||||
normalized_chat_type = str(chat_type or "").strip()
|
||||
normalized_chat_id = str(chat_id or "").strip()
|
||||
normalized_message_id = str(message_id or "").strip()
|
||||
|
||||
if not (
|
||||
normalized_account_id
|
||||
and normalized_chat_type
|
||||
and normalized_chat_id
|
||||
and normalized_message_id
|
||||
):
|
||||
return
|
||||
|
||||
normalized_message_time = float(message_time or 0.0)
|
||||
normalized_message_seq = self._normalize_message_seq(message_seq)
|
||||
updated_at = time.time()
|
||||
|
||||
def _operation(conn: sqlite3.Connection) -> None:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT last_message_id, last_message_time, last_message_seq
|
||||
FROM napcat_chat_checkpoint
|
||||
WHERE account_id = ? AND scope = ? AND chat_type = ? AND chat_id = ?
|
||||
""",
|
||||
(
|
||||
normalized_account_id,
|
||||
normalized_scope,
|
||||
normalized_chat_type,
|
||||
normalized_chat_id,
|
||||
),
|
||||
)
|
||||
existing_row = cursor.fetchone()
|
||||
if existing_row is not None and not self._should_advance_checkpoint(
|
||||
existing_row=existing_row,
|
||||
message_id=normalized_message_id,
|
||||
message_time=normalized_message_time,
|
||||
message_seq=normalized_message_seq,
|
||||
):
|
||||
return
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO napcat_chat_checkpoint (
|
||||
account_id,
|
||||
scope,
|
||||
chat_type,
|
||||
chat_id,
|
||||
last_message_id,
|
||||
last_message_time,
|
||||
last_message_seq,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id, scope, chat_type, chat_id) DO UPDATE SET
|
||||
last_message_id = excluded.last_message_id,
|
||||
last_message_time = excluded.last_message_time,
|
||||
last_message_seq = excluded.last_message_seq,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
normalized_account_id,
|
||||
normalized_scope,
|
||||
normalized_chat_type,
|
||||
normalized_chat_id,
|
||||
normalized_message_id,
|
||||
normalized_message_time,
|
||||
normalized_message_seq,
|
||||
updated_at,
|
||||
),
|
||||
)
|
||||
|
||||
await self._execute_locked(_operation)
|
||||
|
||||
async def has_recovered_message_seen(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
scope: str = "",
|
||||
chat_type: str,
|
||||
chat_id: str,
|
||||
external_message_id: str,
|
||||
) -> bool:
|
||||
"""判断某条历史补拉消息是否已经被当前仓库记录过。"""
|
||||
|
||||
normalized_account_id = str(account_id or "").strip()
|
||||
normalized_scope = self._normalize_scope(scope)
|
||||
normalized_chat_type = str(chat_type or "").strip()
|
||||
normalized_chat_id = str(chat_id or "").strip()
|
||||
normalized_external_message_id = str(external_message_id or "").strip()
|
||||
|
||||
if not (
|
||||
normalized_account_id
|
||||
and normalized_chat_type
|
||||
and normalized_chat_id
|
||||
and normalized_external_message_id
|
||||
):
|
||||
return False
|
||||
|
||||
def _operation(conn: sqlite3.Connection) -> bool:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM napcat_recovery_seen
|
||||
WHERE account_id = ?
|
||||
AND scope = ?
|
||||
AND chat_type = ?
|
||||
AND chat_id = ?
|
||||
AND external_message_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
normalized_account_id,
|
||||
normalized_scope,
|
||||
normalized_chat_type,
|
||||
normalized_chat_id,
|
||||
normalized_external_message_id,
|
||||
),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
return await self._execute_locked(_operation)
|
||||
|
||||
async def mark_recovered_message_seen(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
scope: str = "",
|
||||
chat_type: str,
|
||||
chat_id: str,
|
||||
external_message_id: str,
|
||||
) -> None:
|
||||
"""将一条历史补拉消息标记为已尝试处理。"""
|
||||
|
||||
normalized_account_id = str(account_id or "").strip()
|
||||
normalized_scope = self._normalize_scope(scope)
|
||||
normalized_chat_type = str(chat_type or "").strip()
|
||||
normalized_chat_id = str(chat_id or "").strip()
|
||||
normalized_external_message_id = str(external_message_id or "").strip()
|
||||
|
||||
if not (
|
||||
normalized_account_id
|
||||
and normalized_chat_type
|
||||
and normalized_chat_id
|
||||
and normalized_external_message_id
|
||||
):
|
||||
return
|
||||
|
||||
def _operation(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO napcat_recovery_seen (
|
||||
account_id,
|
||||
scope,
|
||||
chat_type,
|
||||
chat_id,
|
||||
external_message_id,
|
||||
seen_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
normalized_account_id,
|
||||
normalized_scope,
|
||||
normalized_chat_type,
|
||||
normalized_chat_id,
|
||||
normalized_external_message_id,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
await self._execute_locked(_operation)
|
||||
|
||||
async def prune_recovery_seen(self, ttl_seconds: float) -> int:
|
||||
"""删除超过保留期的历史补拉去重记录。"""
|
||||
|
||||
normalized_ttl_seconds = max(0.0, float(ttl_seconds or 0.0))
|
||||
if normalized_ttl_seconds <= 0.0:
|
||||
return 0
|
||||
|
||||
cutoff_timestamp = time.time() - normalized_ttl_seconds
|
||||
|
||||
def _operation(conn: sqlite3.Connection) -> int:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM napcat_recovery_seen WHERE seen_at < ?",
|
||||
(cutoff_timestamp,),
|
||||
)
|
||||
return int(cursor.rowcount or 0)
|
||||
|
||||
return await self._execute_locked(_operation)
|
||||
|
||||
async def _execute_locked(self, operation: Callable[[sqlite3.Connection], T]) -> T:
|
||||
"""在锁保护下打开 SQLite 并执行一次原子操作。"""
|
||||
|
||||
async with self._store_lock:
|
||||
conn = self._open_connection()
|
||||
try:
|
||||
self._ensure_schema(conn)
|
||||
result = operation(conn)
|
||||
conn.commit()
|
||||
return result
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _open_connection(self) -> sqlite3.Connection:
|
||||
"""打开一个带 Row 工厂的 SQLite 连接。"""
|
||||
|
||||
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(self._storage_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _ensure_schema(self, conn: sqlite3.Connection) -> None:
|
||||
"""确保 SQLite 表结构已经准备完成。"""
|
||||
|
||||
if self._schema_ready:
|
||||
return
|
||||
|
||||
for statement in _SCHEMA_STATEMENTS:
|
||||
conn.execute(statement)
|
||||
self._schema_ready = True
|
||||
|
||||
@staticmethod
|
||||
def _normalize_scope(scope: str | None) -> str:
|
||||
"""将空作用域统一折叠为空字符串。"""
|
||||
|
||||
return str(scope or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_message_seq(message_seq: object) -> int | None:
|
||||
"""将消息序号规范化为可选整数。"""
|
||||
|
||||
try:
|
||||
if message_seq is None or str(message_seq).strip() == "":
|
||||
return None
|
||||
return int(message_seq)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _should_advance_checkpoint(
|
||||
cls,
|
||||
*,
|
||||
existing_row: sqlite3.Row,
|
||||
message_id: str,
|
||||
message_time: float,
|
||||
message_seq: int | None,
|
||||
) -> bool:
|
||||
"""判断新的锚点是否应覆盖旧锚点。"""
|
||||
|
||||
existing_message_id = str(existing_row["last_message_id"] or "").strip()
|
||||
existing_message_time = float(existing_row["last_message_time"] or 0.0)
|
||||
existing_message_seq = cls._normalize_message_seq(existing_row["last_message_seq"])
|
||||
|
||||
if message_seq is not None and existing_message_seq is not None:
|
||||
if message_seq != existing_message_seq:
|
||||
return message_seq > existing_message_seq
|
||||
if message_id == existing_message_id:
|
||||
return False
|
||||
return message_time >= existing_message_time
|
||||
|
||||
if message_time != existing_message_time:
|
||||
return message_time > existing_message_time
|
||||
|
||||
if message_id == existing_message_id:
|
||||
return False
|
||||
|
||||
if message_seq is not None and existing_message_seq is None:
|
||||
return True
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,59 @@
|
||||
"""NapCat 官方机器人消息拦截服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .query_service import NapCatQueryService
|
||||
|
||||
|
||||
class NapCatOfficialBotGuard:
|
||||
"""根据群成员资料判断是否应拦截 QQ 官方机器人消息。"""
|
||||
|
||||
def __init__(self, logger: Any, query_service: NapCatQueryService) -> None:
|
||||
"""初始化官方机器人拦截服务。
|
||||
|
||||
Args:
|
||||
logger: 插件日志对象。
|
||||
query_service: NapCat 查询服务。
|
||||
"""
|
||||
self._logger = logger
|
||||
self._query_service = query_service
|
||||
self._cache: Dict[str, bool] = {}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清空机器人识别缓存。"""
|
||||
self._cache.clear()
|
||||
|
||||
async def should_reject(self, sender_user_id: str, group_id: str, ban_qq_bot: bool) -> bool:
|
||||
"""判断是否应拦截当前消息。
|
||||
|
||||
Args:
|
||||
sender_user_id: 发送者用户号。
|
||||
group_id: 群号。
|
||||
ban_qq_bot: 是否启用官方机器人拦截。
|
||||
|
||||
Returns:
|
||||
bool: 若应拦截,则返回 ``True``。
|
||||
"""
|
||||
if not ban_qq_bot or not group_id:
|
||||
return False
|
||||
|
||||
cache_key = f"{group_id}:{sender_user_id}"
|
||||
cached_result = self._cache.get(cache_key)
|
||||
if cached_result is not None:
|
||||
if cached_result:
|
||||
self._logger.warning("QQ 官方机器人消息拦截已启用,消息被丢弃")
|
||||
return cached_result
|
||||
|
||||
member_info = await self._query_service.get_group_member_info(group_id, sender_user_id, no_cache=True)
|
||||
if member_info is None:
|
||||
self._logger.warning("无法获取用户是否为机器人,默认放行当前消息")
|
||||
self._cache[cache_key] = False
|
||||
return False
|
||||
|
||||
should_reject = bool(member_info.get("is_robot"))
|
||||
self._cache[cache_key] = should_reject
|
||||
if should_reject:
|
||||
self._logger.warning("QQ 官方机器人消息拦截已启用,消息被丢弃")
|
||||
return should_reject
|
||||
585
plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py
Normal file
585
plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""NapCat QQ 平台查询服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Mapping, Optional
|
||||
|
||||
from ..types import NapCatActionParams, NapCatActionResponse, NapCatPayloadDict, NapCatPayloadList
|
||||
from .action_service import NapCatActionService
|
||||
|
||||
|
||||
class NapCatQueryService:
|
||||
"""NapCat QQ 平台查询与管理动作服务。"""
|
||||
|
||||
def __init__(self, action_service: NapCatActionService, logger: Any) -> None:
|
||||
"""初始化查询服务。
|
||||
|
||||
Args:
|
||||
action_service: NapCat 底层动作服务。
|
||||
logger: 插件日志对象。
|
||||
"""
|
||||
self._action_service = action_service
|
||||
self._logger = logger
|
||||
|
||||
async def call_action(self, action_name: str, params: NapCatActionParams) -> NapCatActionResponse:
|
||||
"""调用 OneBot 动作并要求返回成功结果。
|
||||
|
||||
Args:
|
||||
action_name: OneBot 动作名称。
|
||||
params: 动作参数。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 返回的原始响应字典。
|
||||
"""
|
||||
return await self._action_service.call_action(action_name, params)
|
||||
|
||||
async def call_action_data(self, action_name: str, params: NapCatActionParams) -> Any:
|
||||
"""调用 OneBot 动作并返回 ``data`` 字段。
|
||||
|
||||
Args:
|
||||
action_name: OneBot 动作名称。
|
||||
params: 动作参数。
|
||||
|
||||
Returns:
|
||||
Any: NapCat 响应中的 ``data`` 字段。
|
||||
"""
|
||||
return await self._action_service.call_action_data(action_name, params)
|
||||
|
||||
async def get_login_info(self) -> Optional[NapCatPayloadDict]:
|
||||
"""获取当前登录账号信息。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 登录信息字典;返回值不是字典时为 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_login_info", {})
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_stranger_info(self, user_id: str, no_cache: bool = False) -> Optional[NapCatPayloadDict]:
|
||||
"""获取陌生人信息。
|
||||
|
||||
Args:
|
||||
user_id: 用户号。
|
||||
no_cache: 是否禁用缓存。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 陌生人信息字典;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data(
|
||||
"get_stranger_info",
|
||||
{"user_id": user_id, "no_cache": bool(no_cache)},
|
||||
)
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_friend_list(self, no_cache: bool = False) -> Optional[NapCatPayloadList]:
|
||||
"""获取好友列表。
|
||||
|
||||
Args:
|
||||
no_cache: 是否禁用缓存。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadList]: 好友信息列表;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_friend_list", {"no_cache": bool(no_cache)})
|
||||
return self._normalize_payload_list(response_data, action_name="get_friend_list")
|
||||
|
||||
async def get_group_info(self, group_id: str) -> Optional[NapCatPayloadDict]:
|
||||
"""获取群信息。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 群信息字典;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_group_info", {"group_id": group_id})
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_group_detail_info(self, group_id: str) -> Optional[NapCatPayloadDict]:
|
||||
"""获取群详细信息。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 群详细信息字典;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_group_detail_info", {"group_id": group_id})
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_group_list(self, no_cache: bool = False) -> Optional[NapCatPayloadList]:
|
||||
"""获取群列表。
|
||||
|
||||
Args:
|
||||
no_cache: 是否禁用缓存。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadList]: 群信息列表;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_group_list", {"no_cache": bool(no_cache)})
|
||||
return self._normalize_payload_list(response_data, action_name="get_group_list")
|
||||
|
||||
async def get_group_at_all_remain(self, group_id: str) -> Optional[NapCatPayloadDict]:
|
||||
"""获取群 @ 全体成员剩余次数。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 剩余次数信息;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_group_at_all_remain", {"group_id": group_id})
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
no_cache: bool = True,
|
||||
) -> Optional[NapCatPayloadDict]:
|
||||
"""获取群成员信息。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
user_id: 用户号。
|
||||
no_cache: 是否禁用缓存。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 群成员信息字典;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data(
|
||||
"get_group_member_info",
|
||||
{"group_id": group_id, "user_id": user_id, "no_cache": bool(no_cache)},
|
||||
)
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_group_member_list(self, group_id: str, no_cache: bool = False) -> Optional[NapCatPayloadList]:
|
||||
"""获取群成员列表。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
no_cache: 是否禁用缓存。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadList]: 群成员信息列表;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data(
|
||||
"get_group_member_list",
|
||||
{"group_id": group_id, "no_cache": bool(no_cache)},
|
||||
)
|
||||
return self._normalize_payload_list(response_data, action_name="get_group_member_list")
|
||||
|
||||
async def get_message_detail(self, message_id: str) -> Optional[NapCatPayloadDict]:
|
||||
"""获取消息详情。
|
||||
|
||||
Args:
|
||||
message_id: 消息 ID。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 消息详情字典;失败时返回 ``None``。
|
||||
"""
|
||||
response_data = await self._safe_call_action_data("get_msg", {"message_id": message_id})
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def get_friend_message_history(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
message_seq: int | None = None,
|
||||
count: int = 20,
|
||||
reverse_order: bool = False,
|
||||
) -> Optional[NapCatPayloadList]:
|
||||
"""获取私聊历史消息列表。"""
|
||||
|
||||
params: NapCatActionResponse = {
|
||||
"user_id": user_id,
|
||||
"count": max(1, int(count)),
|
||||
"reverse_order": bool(reverse_order),
|
||||
}
|
||||
if message_seq is not None:
|
||||
params["message_seq"] = int(message_seq)
|
||||
response_data = await self._safe_call_action_data("get_friend_msg_history", params)
|
||||
return self._normalize_payload_list(response_data, action_name="get_friend_msg_history")
|
||||
|
||||
async def get_group_message_history(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
message_seq: int | None = None,
|
||||
count: int = 20,
|
||||
reverse_order: bool = False,
|
||||
) -> Optional[NapCatPayloadList]:
|
||||
"""获取群聊历史消息列表。"""
|
||||
|
||||
params: NapCatActionResponse = {
|
||||
"group_id": group_id,
|
||||
"count": max(1, int(count)),
|
||||
"reverse_order": bool(reverse_order),
|
||||
}
|
||||
if message_seq is not None:
|
||||
params["message_seq"] = int(message_seq)
|
||||
response_data = await self._safe_call_action_data("get_group_msg_history", params)
|
||||
return self._normalize_payload_list(response_data, action_name="get_group_msg_history")
|
||||
|
||||
async def get_forward_message(
|
||||
self,
|
||||
message_id: Optional[str] = None,
|
||||
forward_id: Optional[str] = None,
|
||||
) -> Optional[NapCatPayloadDict]:
|
||||
"""获取合并转发消息详情。
|
||||
|
||||
Args:
|
||||
message_id: 转发消息 ID。
|
||||
forward_id: NapCat 官方文档中的兼容字段 ``id``。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 合并转发消息详情;失败时返回 ``None``。
|
||||
"""
|
||||
params: NapCatActionResponse = {}
|
||||
if message_id:
|
||||
params["message_id"] = message_id
|
||||
if forward_id:
|
||||
params["id"] = forward_id
|
||||
if not params:
|
||||
raise ValueError("message_id 或 id 至少提供一个")
|
||||
|
||||
response_data = await self._safe_call_action_data("get_forward_msg", params)
|
||||
return self._normalize_forward_payload(response_data)
|
||||
|
||||
async def get_record_detail(
|
||||
self,
|
||||
file_name: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
out_format: str = "wav",
|
||||
) -> Optional[NapCatPayloadDict]:
|
||||
"""获取语音文件详情。
|
||||
|
||||
Args:
|
||||
file_name: 语音文件名。
|
||||
file_id: 可选文件 ID。
|
||||
out_format: 输出格式。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 语音详情字典;失败时返回 ``None``。
|
||||
"""
|
||||
params: NapCatActionResponse = {}
|
||||
if file_name:
|
||||
params["file"] = file_name
|
||||
if file_id:
|
||||
params["file_id"] = file_id
|
||||
if out_format:
|
||||
params["out_format"] = out_format
|
||||
if not params.get("file") and not params.get("file_id"):
|
||||
raise ValueError("file 或 file_id 至少提供一个")
|
||||
|
||||
response_data = await self._safe_call_action_data("get_record", params)
|
||||
return response_data if isinstance(response_data, dict) else None
|
||||
|
||||
async def set_group_ban(self, group_id: int, user_id: int, duration: int) -> NapCatActionResponse:
|
||||
"""设置群成员禁言。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
user_id: 用户号。
|
||||
duration: 禁言秒数。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"set_group_ban",
|
||||
{"group_id": group_id, "user_id": user_id, "duration": duration},
|
||||
)
|
||||
|
||||
async def set_group_whole_ban(self, group_id: int, enable: bool) -> NapCatActionResponse:
|
||||
"""设置群全体禁言。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
enable: 是否开启全体禁言。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"set_group_whole_ban",
|
||||
{"group_id": group_id, "enable": bool(enable)},
|
||||
)
|
||||
|
||||
async def set_group_kick(
|
||||
self,
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
reject_add_request: bool = False,
|
||||
) -> NapCatActionResponse:
|
||||
"""踢出群成员。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
user_id: 用户号。
|
||||
reject_add_request: 是否拒绝再次加群。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"set_group_kick",
|
||||
{
|
||||
"group_id": group_id,
|
||||
"user_id": user_id,
|
||||
"reject_add_request": bool(reject_add_request),
|
||||
},
|
||||
)
|
||||
|
||||
async def set_group_kick_members(
|
||||
self,
|
||||
group_id: int,
|
||||
user_ids: List[int],
|
||||
reject_add_request: bool = False,
|
||||
) -> NapCatActionResponse:
|
||||
"""批量踢出群成员。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
user_ids: 用户号列表。
|
||||
reject_add_request: 是否拒绝再次加群。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"set_group_kick_members",
|
||||
{
|
||||
"group_id": group_id,
|
||||
"user_id": user_ids,
|
||||
"reject_add_request": bool(reject_add_request),
|
||||
},
|
||||
)
|
||||
|
||||
async def send_poke(
|
||||
self,
|
||||
user_id: int,
|
||||
group_id: Optional[int] = None,
|
||||
target_id: Optional[int] = None,
|
||||
) -> NapCatActionResponse:
|
||||
"""发送戳一戳。
|
||||
|
||||
Args:
|
||||
user_id: 目标用户号。
|
||||
group_id: 可选群号;私聊时为空。
|
||||
target_id: NapCat 官方 ``send_poke`` 动作支持的目标 ID。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
params: NapCatActionResponse = {"user_id": user_id}
|
||||
if group_id is not None:
|
||||
params["group_id"] = group_id
|
||||
if target_id is not None:
|
||||
params["target_id"] = target_id
|
||||
return await self.call_action("send_poke", params)
|
||||
|
||||
async def delete_message(self, message_id: int) -> NapCatActionResponse:
|
||||
"""撤回消息。
|
||||
|
||||
Args:
|
||||
message_id: 消息 ID。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action("delete_msg", {"message_id": message_id})
|
||||
|
||||
async def send_group_ai_record(self, group_id: int, character: str, text: str) -> NapCatActionResponse:
|
||||
"""发送群 AI 语音。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
character: 角色标识。
|
||||
text: 语音文本。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"send_group_ai_record",
|
||||
{"group_id": group_id, "character": character, "text": text},
|
||||
)
|
||||
|
||||
async def set_message_emoji_like(
|
||||
self,
|
||||
message_id: int,
|
||||
emoji_id: int,
|
||||
set_like: bool = True,
|
||||
) -> NapCatActionResponse:
|
||||
"""给消息贴表情或取消表情。
|
||||
|
||||
Args:
|
||||
message_id: 消息 ID。
|
||||
emoji_id: 表情 ID。
|
||||
set_like: 是否设置为已贴表情。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"set_msg_emoji_like",
|
||||
{"message_id": message_id, "emoji_id": emoji_id, "set": bool(set_like)},
|
||||
)
|
||||
|
||||
async def set_group_name(self, group_id: int, group_name: str) -> NapCatActionResponse:
|
||||
"""设置群名称。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
group_name: 新群名称。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
return await self.call_action(
|
||||
"set_group_name",
|
||||
{"group_id": group_id, "group_name": group_name},
|
||||
)
|
||||
|
||||
async def set_qq_profile(
|
||||
self,
|
||||
nickname: str,
|
||||
personal_note: str = "",
|
||||
sex: str = "",
|
||||
) -> NapCatActionResponse:
|
||||
"""设置 QQ 账号资料。
|
||||
|
||||
Args:
|
||||
nickname: 新昵称。
|
||||
personal_note: 个性签名。
|
||||
sex: 性别,支持 ``male``、``female``、``unknown``。
|
||||
|
||||
Returns:
|
||||
NapCatActionResponse: NapCat 原始响应字典。
|
||||
"""
|
||||
params: NapCatActionResponse = {"nickname": nickname}
|
||||
if personal_note:
|
||||
params["personal_note"] = personal_note
|
||||
if sex:
|
||||
params["sex"] = sex
|
||||
return await self.call_action("set_qq_profile", params)
|
||||
|
||||
async def download_binary(self, url: str) -> Optional[bytes]:
|
||||
"""下载远程二进制资源。
|
||||
|
||||
Args:
|
||||
url: 资源 URL。
|
||||
|
||||
Returns:
|
||||
Optional[bytes]: 下载到的二进制内容;失败时返回 ``None``。
|
||||
"""
|
||||
return await self._action_service.download_binary(url)
|
||||
|
||||
async def _safe_call_action_data(self, action_name: str, params: NapCatActionParams) -> Any:
|
||||
"""安全调用 OneBot 动作并返回 ``data`` 字段。
|
||||
|
||||
Args:
|
||||
action_name: OneBot 动作名称。
|
||||
params: 动作参数。
|
||||
|
||||
Returns:
|
||||
Any: 响应中的 ``data`` 字段;失败时返回 ``None``。
|
||||
"""
|
||||
return await self._action_service.safe_call_action_data(action_name, params)
|
||||
|
||||
def _normalize_payload_list(self, response_data: Any, action_name: str) -> Optional[NapCatPayloadList]:
|
||||
"""将列表类响应归一化为字典列表。
|
||||
|
||||
NapCat 在不同版本或不同动作下,``data`` 可能直接返回列表,
|
||||
也可能再包一层字典,例如 ``{\"members\": [...]}``。
|
||||
|
||||
Args:
|
||||
response_data: 原始 ``data`` 字段。
|
||||
action_name: 当前动作名称。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadList]: 归一化后的列表;无法识别时返回 ``None``。
|
||||
"""
|
||||
|
||||
if isinstance(response_data, list):
|
||||
return [dict(item) for item in response_data if isinstance(item, Mapping)]
|
||||
|
||||
if not isinstance(response_data, Mapping):
|
||||
self._logger.warning(
|
||||
"NapCat 列表接口返回了无法识别的数据类型: action=%s type=%s payload=%r",
|
||||
action_name,
|
||||
type(response_data).__name__,
|
||||
response_data,
|
||||
)
|
||||
return None
|
||||
|
||||
for key in (
|
||||
"list",
|
||||
"items",
|
||||
"members",
|
||||
"member_list",
|
||||
"group_list",
|
||||
"friend_list",
|
||||
"friends",
|
||||
"records",
|
||||
"rows",
|
||||
"data",
|
||||
):
|
||||
candidate = response_data.get(key)
|
||||
if isinstance(candidate, list):
|
||||
return [dict(item) for item in candidate if isinstance(item, Mapping)]
|
||||
|
||||
for candidate in response_data.values():
|
||||
if isinstance(candidate, list):
|
||||
return [dict(item) for item in candidate if isinstance(item, Mapping)]
|
||||
|
||||
self._logger.warning(
|
||||
"NapCat 列表接口返回了无法归一化的字典结构: action=%s payload=%r",
|
||||
action_name,
|
||||
response_data,
|
||||
)
|
||||
return None
|
||||
|
||||
def _normalize_forward_payload(self, response_data: Any) -> Optional[NapCatPayloadDict]:
|
||||
"""将合并转发响应归一化为统一字典结构。
|
||||
|
||||
NapCat 的 ``get_forward_msg`` 在不同版本下,``data`` 可能直接返回节点列表,
|
||||
也可能返回 ``{\"messages\": [...]}``,甚至包在 ``content`` 字段中。
|
||||
|
||||
Args:
|
||||
response_data: ``get_forward_msg`` 的原始 ``data`` 字段。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 归一化后的转发消息详情;失败时返回 ``None``。
|
||||
"""
|
||||
if isinstance(response_data, list):
|
||||
return {"messages": [dict(item) for item in response_data if isinstance(item, Mapping)]}
|
||||
|
||||
if not isinstance(response_data, Mapping):
|
||||
self._logger.warning(
|
||||
"NapCat 转发接口返回了无法识别的数据类型: type=%s payload=%r",
|
||||
type(response_data).__name__,
|
||||
response_data,
|
||||
)
|
||||
return None
|
||||
|
||||
direct_messages = response_data.get("messages")
|
||||
if isinstance(direct_messages, list):
|
||||
return dict(response_data)
|
||||
|
||||
direct_content = response_data.get("content")
|
||||
if isinstance(direct_content, list):
|
||||
return {"messages": [dict(item) for item in direct_content if isinstance(item, Mapping)]}
|
||||
|
||||
nested_data = response_data.get("data")
|
||||
if isinstance(nested_data, Mapping):
|
||||
nested_messages = nested_data.get("messages")
|
||||
if isinstance(nested_messages, list):
|
||||
return {"messages": [dict(item) for item in nested_messages if isinstance(item, Mapping)]}
|
||||
|
||||
nested_content = nested_data.get("content")
|
||||
if isinstance(nested_content, list):
|
||||
return {"messages": [dict(item) for item in nested_content if isinstance(item, Mapping)]}
|
||||
|
||||
self._logger.warning("NapCat 转发接口未返回可识别的转发节点列表: payload=%r", response_data)
|
||||
return None
|
||||
Reference in New Issue
Block a user