From a2c0cfdf5d4c5f8ac8011e837e0e5e8732d45f92 Mon Sep 17 00:00:00 2001 From: Copaan <98086483+Copaan@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:14:03 +0800 Subject: [PATCH] =?UTF-8?q?bugfix:=E4=BF=AE=E5=A4=8D=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=88=A4=E6=96=AD=E9=97=AE=E9=A2=98=E3=80=81?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B6=85=E7=BA=A7=E7=94=A8=E6=88=B7=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E8=B1=81=E5=85=8D=E9=97=AE=E9=A2=98=20(#2141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bugfix:修复命令前缀判断问题、修复超级用户无法豁免问题 * bugfix:修复格式问题 --- .../builtin_plugins/hooks/auth/auth_limit.py | 2 +- .../builtin_plugins/hooks/auth_activation.py | 42 ++++++++++++- zhenxun/builtin_plugins/hooks/auth_checker.py | 61 +++++++++++-------- .../builtin_plugins/hooks/auth_pipeline.py | 6 +- zhenxun/builtin_plugins/hooks/auth_policy.py | 22 ++++++- .../hooks/auth_runtime_config.py | 2 +- zhenxun/builtin_plugins/hooks/chkdsk_hook.py | 12 ++++ .../statistics/statistics_hook.py | 8 ++- zhenxun/models/statistics.py | 2 +- zhenxun/models/user_console.py | 18 ++++-- zhenxun/models/user_gold_log.py | 8 +++ zhenxun/services/cache/bounded_ttl.py | 38 +++++++++--- zhenxun/services/data_access.py | 4 ++ zhenxun/services/scheduler/engine.py | 2 + zhenxun/services/scheduler/lifecycle.py | 1 + zhenxun/services/scheduler/manager.py | 1 + zhenxun/services/scheduler/types.py | 1 + zhenxun/utils/platform.py | 45 +++++++++++++- 18 files changed, 224 insertions(+), 51 deletions(-) diff --git a/zhenxun/builtin_plugins/hooks/auth/auth_limit.py b/zhenxun/builtin_plugins/hooks/auth/auth_limit.py index 3b570baf..011f2a38 100644 --- a/zhenxun/builtin_plugins/hooks/auth/auth_limit.py +++ b/zhenxun/builtin_plugins/hooks/auth/auth_limit.py @@ -346,7 +346,7 @@ class LimitManager: limit = limit_model.limit limiter = limit_model.limiter is_limit = ( - LimitWatchType.ALL + limit.watch_type == LimitWatchType.ALL or (group_id and limit.watch_type == LimitWatchType.GROUP) or (not group_id and limit.watch_type == LimitWatchType.USER) ) diff --git a/zhenxun/builtin_plugins/hooks/auth_activation.py b/zhenxun/builtin_plugins/hooks/auth_activation.py index 8b2ce9fc..b4c188e8 100644 --- a/zhenxun/builtin_plugins/hooks/auth_activation.py +++ b/zhenxun/builtin_plugins/hooks/auth_activation.py @@ -71,6 +71,7 @@ class HandlerDescriptor: class AlconnaShortcutDescriptor: pattern: str fuzzy: bool = False + prefix: bool = False flags: int = 0 @@ -1059,6 +1060,7 @@ def _extract_alconna_shortcut_descriptors( AlconnaShortcutDescriptor( pattern=pattern, fuzzy=bool(getattr(args, "fuzzy", False)), + prefix=bool(getattr(args, "prefix", False)), flags=int(getattr(args, "flags", 0) or 0), ) ) @@ -1140,7 +1142,7 @@ def matcher_alconna_head_matches( if decision == "unknown": saw_unknown = True for shortcut in alconna.shortcuts: - decision = _alconna_shortcut_matches(text, shortcut) + decision = _alconna_shortcut_matches(text, shortcut, alconna.prefixes) if decision == "match": return "match" if decision == "unknown": @@ -1194,11 +1196,43 @@ def _alconna_literal_head_matches(text: str, head: str, *, compact: bool) -> boo def _alconna_shortcut_matches( text: str, shortcut: AlconnaShortcutDescriptor, + prefixes: tuple[str, ...] = (), ) -> ActivationDecision: pattern = shortcut.pattern.strip() if not pattern: return "unknown" + saw_unknown = False + for normalized in _alconna_shortcut_patterns(pattern, shortcut, prefixes): + decision = _alconna_shortcut_pattern_matches(text, normalized, shortcut) + if decision == "match": + return "match" + if decision == "unknown": + saw_unknown = True + return "unknown" if saw_unknown else "miss" + + +def _alconna_shortcut_patterns( + pattern: str, + shortcut: AlconnaShortcutDescriptor, + prefixes: tuple[str, ...], +) -> tuple[str, ...]: normalized = normalize_shortcut_pattern(pattern) + if not normalized: + return () + patterns = [normalized] + if shortcut.prefix: + for prefix in prefixes or ("",): + candidate = normalize_shortcut_pattern(f"{prefix}{normalized}") + if candidate and candidate not in patterns: + patterns.append(candidate) + return tuple(patterns) + + +def _alconna_shortcut_pattern_matches( + text: str, + normalized: str, + shortcut: AlconnaShortcutDescriptor, +) -> ActivationDecision: placeholder_match = _placeholder_shortcut_decision(text, normalized) if placeholder_match == "match": return "match" @@ -1211,8 +1245,10 @@ def _alconna_shortcut_matches( return "match" try: if shortcut.fuzzy: - return "match" if re.match(f"^{pattern}", text, shortcut.flags) else "miss" - return "match" if re.fullmatch(pattern, text, shortcut.flags) else "miss" + return ( + "match" if re.match(f"^{normalized}", text, shortcut.flags) else "miss" + ) + return "match" if re.fullmatch(normalized, text, shortcut.flags) else "miss" except re.error: return "unknown" diff --git a/zhenxun/builtin_plugins/hooks/auth_checker.py b/zhenxun/builtin_plugins/hooks/auth_checker.py index 60edee43..9c8636fb 100644 --- a/zhenxun/builtin_plugins/hooks/auth_checker.py +++ b/zhenxun/builtin_plugins/hooks/auth_checker.py @@ -496,10 +496,11 @@ def _event_text_candidates( candidates.append(normalized) add(_trie_command_text_from_state(state)) - add(_trie_raw_command_from_state(state)) + trie_raw = _trie_raw_command_from_state(state) + add(trie_raw) trie_arg = _trie_command_arg_text_from_state(state) - if _trie_raw_command_from_state(state) and trie_arg: - add(f"{_trie_raw_command_from_state(state)} {trie_arg}") + if trie_raw and trie_arg: + add(f"{trie_raw} {trie_arg}") add(plain_text) if event is not None: with contextlib.suppress(Exception): @@ -794,9 +795,10 @@ def _prepare_handle_event_state(event: Event, state: dict) -> None: def _build_matcher_state(base_state: dict) -> dict: + # 第一次调用即在 base_state 写入副作用缓存对象;copy() 后 matcher_state + # 与之共享同一引用,无需二次 get(B7)。 get_permission_side_effect_cache(state=base_state) matcher_state = base_state.copy() - get_permission_side_effect_cache(state=matcher_state) return matcher_state @@ -965,25 +967,28 @@ async def _db_section(): DB_ACTIVE_COUNT = max(DB_ACTIVE_COUNT - 1, 0) +_POLICY_SKIP_MESSAGES = { + "user_or_group_banned": "user or group banned (cached)", + "superuser_required": "超级管理员权限不足...", + "admin_required": "管理员权限不足...", + "bot_not_found": "Bot不存在,阻断权限检测...", + "bot_sleeping": "Bot休眠中阻断权限检测...", + "bot_plugin_blocked": "Bot插件权限检查结果为关闭...", + "group_not_found": "群组信息不存在...", + "group_blacklisted": "群组黑名单, 目标群组群权限权限-1...", + "group_sleeping": "群组休眠状态...", + "group_level_low": "群等级限制...", + "admin_level_low": "管理员权限不足...", + "plugin_disabled_in_group": "该插件在群组中已被禁用...", + "plugin_superuser_blocked_in_group": "超级管理员禁用了该群此功能...", + "plugin_blocked_in_group": "该群未开启此功能...", + "plugin_disabled_in_private": "该插件在私聊中已被禁用...", + "plugin_global_disabled": "全局未开启此功能...", +} + + def _policy_skip_message(reason: str) -> str: - return { - "user_or_group_banned": "user or group banned (cached)", - "superuser_required": "超级管理员权限不足...", - "admin_required": "管理员权限不足...", - "bot_not_found": "Bot不存在,阻断权限检测...", - "bot_sleeping": "Bot休眠中阻断权限检测...", - "bot_plugin_blocked": "Bot插件权限检查结果为关闭...", - "group_not_found": "群组信息不存在...", - "group_blacklisted": "群组黑名单, 目标群组群权限权限-1...", - "group_sleeping": "群组休眠状态...", - "group_level_low": "群等级限制...", - "admin_level_low": "管理员权限不足...", - "plugin_disabled_in_group": "该插件在群组中已被禁用...", - "plugin_superuser_blocked_in_group": "超级管理员禁用了该群此功能...", - "plugin_blocked_in_group": "该群未开启此功能...", - "plugin_disabled_in_private": "该插件在私聊中已被禁用...", - "plugin_global_disabled": "全局未开启此功能...", - }.get(reason, reason or "permission denied") + return _POLICY_SKIP_MESSAGES.get(reason, reason or "permission denied") # 超时装饰器 @@ -1342,18 +1347,24 @@ async def _check_ban_from_snapshot( hook_recorder: HookTraceRecorder, session: Uninfo, ) -> None: + # skip_ban 上移到 cached 判断之前(A7),否则豁免参数对 cached 命中形同虚设。 + if skip_ban: + hook_recorder.set("auth_ban", "skipped") + return + is_superuser = bool(getattr(prep.permission_context, "is_superuser", False)) ban_cache_state = prep.snapshot.ban_state if event_cache is not None: ban_cache_state = event_cache.get("ban_state") if ban_cache_state is True: + # 超级用户豁免(A7):与 PDP / 旧轨权威路径保持一致,避免被 ban 后无法自救。 + if is_superuser: + hook_recorder.set("auth_ban", "cached_superuser_exempt") + return hook_recorder.set("auth_ban", "cached") raise SkipPluginException("user or group banned (cached)") if ban_cache_state is False: hook_recorder.set("auth_ban", "cached") return - if skip_ban: - hook_recorder.set("auth_ban", "skipped") - return ban_start = time.time() try: diff --git a/zhenxun/builtin_plugins/hooks/auth_pipeline.py b/zhenxun/builtin_plugins/hooks/auth_pipeline.py index 6c27a9b4..e59f246d 100644 --- a/zhenxun/builtin_plugins/hooks/auth_pipeline.py +++ b/zhenxun/builtin_plugins/hooks/auth_pipeline.py @@ -246,7 +246,11 @@ async def route_gate_stage( if deps.is_hidden_plugin(ctx.matcher): ctx.stop(allowed=True, effect="allow", reason="hidden_plugin") return - if ctx.event_cache is not None and ctx.event_cache.get("ban_state") is True: + if ( + ctx.event_cache is not None + and ctx.event_cache.get("ban_state") is True + and not ctx.event_context.is_superuser + ): ctx.decision_effect = "skip" ctx.decision_reason = "ban_cached" raise SkipPluginException("user or group banned (cached)") diff --git a/zhenxun/builtin_plugins/hooks/auth_policy.py b/zhenxun/builtin_plugins/hooks/auth_policy.py index 739ec166..a69d66ee 100644 --- a/zhenxun/builtin_plugins/hooks/auth_policy.py +++ b/zhenxun/builtin_plugins/hooks/auth_policy.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from dataclasses import dataclass, field from typing import Any, Literal @@ -116,8 +117,14 @@ class PolicyDecisionPoint: if not bot_data.status and not context.allow_sleep_bypass: return PolicyDecision("deny", "bot_sleeping") module = snapshot.profile.module - if module and self._module_in_block_string(module, bot_data.block_plugins): - return PolicyDecision("deny", "bot_plugin_blocked") + if module: + value = bot_data.block_plugins or "" + # 缓存解析后的 frozenset,避免每次 bot 检查重复 split(B8-3); + # 仍保留原子串判定以保持行为等价。 + if CommonUtils.format(module) in value or module in self._bot_block_set( + bot_data + ): + return PolicyDecision("deny", "bot_plugin_blocked") return PolicyDecision("allow", "bot_allowed") def decide_group(self, context: PolicyContext) -> PolicyDecision: @@ -208,6 +215,17 @@ class PolicyDecisionPoint: setattr(group, "superuser_block_plugin_set", super_block_set) return block_set, super_block_set + @staticmethod + def _bot_block_set(bot_data: object) -> frozenset[str]: + block_set = getattr(bot_data, "block_plugin_set", None) + if block_set is None: + block_set = _parse_block_modules( + getattr(bot_data, "block_plugins", "") or "" + ) + with contextlib.suppress(Exception): + setattr(bot_data, "block_plugin_set", block_set) + return block_set + @staticmethod def _module_in_block_string(module: str, value: str | None) -> bool: if not value: diff --git a/zhenxun/builtin_plugins/hooks/auth_runtime_config.py b/zhenxun/builtin_plugins/hooks/auth_runtime_config.py index 6cbb210e..383a8d07 100644 --- a/zhenxun/builtin_plugins/hooks/auth_runtime_config.py +++ b/zhenxun/builtin_plugins/hooks/auth_runtime_config.py @@ -23,7 +23,7 @@ class AuthDispatchRuntimeConfig: circuit_reset_time: int = 300 matcher_route_prefilter_ttl: int = 2 prefilter_stats_log_interval: float = 10.0 - cache_sweep_interval: float = 1.0 + cache_sweep_interval: float = 45.0 dispatch_stats_log_interval: float = 10.0 diff --git a/zhenxun/builtin_plugins/hooks/chkdsk_hook.py b/zhenxun/builtin_plugins/hooks/chkdsk_hook.py index c2f0ab43..c50b663f 100644 --- a/zhenxun/builtin_plugins/hooks/chkdsk_hook.py +++ b/zhenxun/builtin_plugins/hooks/chkdsk_hook.py @@ -82,6 +82,11 @@ async def _( if matcher.type == "notice": return + # AI 重路由注入的合成事件不计入恶意检测(A6):AI 链路有自己的预算/审批, + # 不应被人类反垃圾逻辑封禁(此前批量转发误封超级用户的事故根因之一)。 + if getattr(event, "_ai_triggered", False): + return + # 提前判断插件类型,跳过不需要检测的插件 if plugin := matcher.plugin: if metadata := plugin.metadata: @@ -99,6 +104,13 @@ async def _( user_id = resolve_actor_user_id(event, session.id1) group_id = resolve_event_group_id(event, session.id3 or session.id2) + # 超级用户豁免恶意检测(A6):与权威权限路径保持一致,避免误封管理者。 + if user_id: + is_superuser = state.get("_zx_is_superuser") + if not isinstance(is_superuser, bool): + is_superuser = user_id in bot.config.superusers + if is_superuser: + return malicious_check_time = float(_get_positive_config("MALICIOUS_CHECK_TIME", float)) malicious_ban_count = int(_get_positive_config("MALICIOUS_BAN_COUNT", int)) malicious_ban_time = int(_get_positive_config("MALICIOUS_BAN_TIME", int)) diff --git a/zhenxun/builtin_plugins/statistics/statistics_hook.py b/zhenxun/builtin_plugins/statistics/statistics_hook.py index 64e9e3d7..1c323a56 100644 --- a/zhenxun/builtin_plugins/statistics/statistics_hook.py +++ b/zhenxun/builtin_plugins/statistics/statistics_hook.py @@ -53,8 +53,12 @@ async def _flush_statistics_buffer(reason: str) -> int: async def _append_statistics(record: Statistics) -> None: - TEMP_LIST.append(record) - if len(TEMP_LIST) >= STATS_BUFFER_FLUSH_SIZE and not _STATS_FLUSH_LOCK.locked(): + # 在锁内追加(B8),与 flush 的 copy+clear 串行,消除逻辑窗口; + # flush 自身再次获取同一把锁,故在锁外触发避免重入。 + async with _STATS_FLUSH_LOCK: + TEMP_LIST.append(record) + should_flush = len(TEMP_LIST) >= STATS_BUFFER_FLUSH_SIZE + if should_flush: await _flush_statistics_buffer("缓冲区触发") diff --git a/zhenxun/models/statistics.py b/zhenxun/models/statistics.py index a4a9fc4e..794ac5fb 100644 --- a/zhenxun/models/statistics.py +++ b/zhenxun/models/statistics.py @@ -15,7 +15,7 @@ class Statistics(Model): """群聊id""" plugin_name = fields.CharField(255) """插件名称""" - create_time = fields.DatetimeField(auto_now=True) + create_time = fields.DatetimeField(auto_now_add=True) """添加日期""" bot_id = fields.CharField(255, null=True) """Bot Id""" diff --git a/zhenxun/models/user_console.py b/zhenxun/models/user_console.py index a25de811..8ed07be8 100644 --- a/zhenxun/models/user_console.py +++ b/zhenxun/models/user_console.py @@ -151,9 +151,11 @@ class UserConsole(Model): source: 来源 platform: 平台. """ - user = await cls._get_user_for_write(user_id=user_id, platform=platform) - user.gold += gold - await user.save(update_fields=["gold"]) + await cls._get_user_for_write(user_id=user_id, platform=platform) + # 原子自增,避免并发 read-modify-write 丢币(A2);filter().update() + # 不触发基类 save() 的缓存失效,需手动失效。 + await cls.filter(user_id=user_id).update(gold=F("gold") + gold) + await cls.invalidate_user_cache(user_id) await append_user_gold_log( user_id=user_id, gold=gold, handle=GoldHandle.GET, source=source ) @@ -182,8 +184,14 @@ class UserConsole(Model): user = await cls._get_user_for_write(user_id=user_id, platform=platform) if user.gold < gold: raise InsufficientGold() - user.gold -= gold - await user.save(update_fields=["gold"]) + # 原子扣减 + gold__gte 守卫,防并发超扣(A2);未命中说明余额已被 + # 其他协程扣走,按金币不足处理。 + updated = await cls.filter(user_id=user_id, gold__gte=gold).update( + gold=F("gold") - gold + ) + if not updated: + raise InsufficientGold() + await cls.invalidate_user_cache(user_id) await append_user_gold_log( user_id=user_id, gold=gold, handle=handle, source=plugin_module ) diff --git a/zhenxun/models/user_gold_log.py b/zhenxun/models/user_gold_log.py index 399a0e2c..78109531 100644 --- a/zhenxun/models/user_gold_log.py +++ b/zhenxun/models/user_gold_log.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from tortoise import fields from zhenxun.services.db_context import Model @@ -21,3 +23,9 @@ class UserGoldLog(Model): class Meta: # pyright: ignore [reportIncompatibleVariableOverride] table = "user_gold_log" table_description = "用户金币记录表" + # 高频写表补索引(H4):按 user_id / (user_id, create_time) 查询流水, + # SchemaGuard 启动时自动建,不改变查询语义。 + indexes: ClassVar = [ + ("user_id",), + ("user_id", "create_time"), + ] diff --git a/zhenxun/services/cache/bounded_ttl.py b/zhenxun/services/cache/bounded_ttl.py index 04dc9622..a56c3b41 100644 --- a/zhenxun/services/cache/bounded_ttl.py +++ b/zhenxun/services/cache/bounded_ttl.py @@ -74,8 +74,12 @@ class BoundedTTLCache(Generic[K, V]): self._sets = 0 self._evictions = 0 self._lock = asyncio.Lock() + self._last_sweep = 0.0 self.__class__._instances.add(self) + # 全量过期清理的最小间隔(B1):避免每次 get 都 O(N) 扫描整表。 + _SWEEP_INTERVAL = 30.0 + def _expire_at(self, now: float) -> float: if self._ttl_seconds <= 0: return sys.float_info.max @@ -106,23 +110,37 @@ class BoundedTTLCache(Generic[K, V]): self._evictions += 1 return True - def _cleanup_nolock(self, now: float) -> None: - expired_keys = [ - key for key, (expire_at, _, _) in self._cache.items() if expire_at <= now - ] - for key in expired_keys: - if self._remove_key_nolock(key): - self._evictions += 1 + def _enforce_capacity_nolock(self) -> None: + """容量边界强制(O(溢出量)):仅在新增后调用,不扫描全表。""" while len(self._cache) > self._max_items: self._pop_oldest_nolock() if self._max_total_bytes is not None: while self._total_bytes > self._max_total_bytes and self._cache: self._pop_oldest_nolock() + def _sweep_expired_nolock(self, now: float) -> None: + """全量过期清理(O(N)):由节流器或后台/governor 低频触发。""" + self._last_sweep = now + expired_keys = [ + key for key, (expire_at, _, _) in self._cache.items() if expire_at <= now + ] + for key in expired_keys: + if self._remove_key_nolock(key): + self._evictions += 1 + + def _maybe_sweep_nolock(self, now: float) -> None: + if now - self._last_sweep >= self._SWEEP_INTERVAL: + self._sweep_expired_nolock(now) + + def _cleanup_nolock(self, now: float) -> None: + """完整清理(全量过期 + 容量):保留给 stats / 显式调用。""" + self._sweep_expired_nolock(now) + self._enforce_capacity_nolock() + async def get(self, key: K) -> V | None: now = time.monotonic() async with self._lock: - self._cleanup_nolock(now) + # 仅做命中项的单条过期检查(O(1)),全量清理改为低频节流(B1)。 item = self._cache.get(key) if item is None: self._misses += 1 @@ -148,7 +166,9 @@ class BoundedTTLCache(Generic[K, V]): self._total_bytes += value_size self._sets += 1 self._cache.move_to_end(key) - self._cleanup_nolock(now) + # 新增后必做容量强制(廉价);全量过期清理走低频节流(B1)。 + self._maybe_sweep_nolock(now) + self._enforce_capacity_nolock() return key in self._cache async def delete(self, key: K) -> bool: diff --git a/zhenxun/services/data_access.py b/zhenxun/services/data_access.py index c3713f1c..22b3ad7d 100644 --- a/zhenxun/services/data_access.py +++ b/zhenxun/services/data_access.py @@ -146,6 +146,10 @@ class DataAccess(Generic[T]): 返回: str | None: 缓存键,如果无法构建则返回None """ + # 含 ORM lookup 后缀(如 channel_id__isnull / x__gte)的查询无法可靠映射到 + # 单条主键缓存键,退化为直查 DB,避免空串与 NULL 语义混淆导致错误命中(A5)。 + if any("__" in key for key in kwargs): + return None if isinstance(self.key_field, tuple): # 多字段主键 key_parts = [] diff --git a/zhenxun/services/scheduler/engine.py b/zhenxun/services/scheduler/engine.py index 9a3f7747..a9073116 100644 --- a/zhenxun/services/scheduler/engine.py +++ b/zhenxun/services/scheduler/engine.py @@ -278,6 +278,7 @@ async def _execute_single_job_instance( schedule_id=schedule.id, plugin_name=plugin_name, bot_id=bot.self_id, + platform_scope=PlatformUtils.get_platform_scope(bot), group_id=group_id, job_kwargs=schedule.job_kwargs if isinstance(schedule.job_kwargs, dict) else {}, ) @@ -381,6 +382,7 @@ async def _execute_job( ) if bot is None: return + context_override.platform_scope = PlatformUtils.get_platform_scope(bot) logger.info(f"开始执行临时任务: {plugin_name}") injected_params = {"context": context_override} state: T_State = {ScheduleContext: context_override} diff --git a/zhenxun/services/scheduler/lifecycle.py b/zhenxun/services/scheduler/lifecycle.py index 1d1ad930..6e41281b 100644 --- a/zhenxun/services/scheduler/lifecycle.py +++ b/zhenxun/services/scheduler/lifecycle.py @@ -82,6 +82,7 @@ async def _load_schedules_from_db(): schedule_id=0, plugin_name=job_id, bot_id=None, + platform_scope=None, group_id=None, job_kwargs={}, ) diff --git a/zhenxun/services/scheduler/manager.py b/zhenxun/services/scheduler/manager.py index 103e4845..3ded5516 100644 --- a/zhenxun/services/scheduler/manager.py +++ b/zhenxun/services/scheduler/manager.py @@ -250,6 +250,7 @@ class SchedulerManager: schedule_id=0, plugin_name=f"runtime::{func.__module__}", bot_id=None, + platform_scope=None, group_id=None, job_kwargs={}, ) diff --git a/zhenxun/services/scheduler/types.py b/zhenxun/services/scheduler/types.py index c05bcf79..d1561bda 100644 --- a/zhenxun/services/scheduler/types.py +++ b/zhenxun/services/scheduler/types.py @@ -105,6 +105,7 @@ class ScheduleContext(BaseModel): schedule_id: int = Field(..., description="数据库中的任务ID") plugin_name: str = Field(..., description="任务所属的插件名称") bot_id: str | None = Field(None, description="执行任务的Bot ID") + platform_scope: str | None = Field(None, description="执行任务的细粒度平台作用域") group_id: str | None = Field(None, description="当前执行实例的目标群组ID") job_kwargs: dict = Field(default_factory=dict, description="任务配置的参数") diff --git a/zhenxun/utils/platform.py b/zhenxun/utils/platform.py index 12dfc830..5ce6c5a7 100644 --- a/zhenxun/utils/platform.py +++ b/zhenxun/utils/platform.py @@ -100,6 +100,46 @@ class PlatformUtils: ) return None + @classmethod + def resolve_bot( + cls, + bot_id: str | None = None, + platform_scope: str | None = None, + log_cmd: str | None = None, + ) -> Bot | None: + """Resolve a bot without randomly selecting the QQ official adapter. + + Background jobs that require OneBot APIs should pass + ``platform_scope="qq_client"``. When no scope is supplied and multiple + bots are online, a single OneBot client is preferred; otherwise the + ambiguous selection is skipped. + """ + if bot_id: + try: + bot = nonebot.get_bot(bot_id) + except KeyError: + logger.warning(f"Bot:{bot_id} 对象未连接或不存在", log_cmd) + return None + if platform_scope and cls.get_platform_scope(bot) != platform_scope: + logger.warning(f"Bot:{bot_id} 平台作用域不匹配,已跳过。", log_cmd) + return None + return bot + + bots = list(nonebot.get_bots().values()) + if platform_scope: + bots = [ + bot for bot in bots if cls.get_platform_scope(bot) == platform_scope + ] + if not bots: + logger.warning("当前没有匹配的 Bot,已跳过。", log_cmd) + return None + if len(bots) == 1: + return bots[0] + if platform_scope is None: + return cls._resolve_unique_qq_client_bot(log_cmd) + logger.warning("存在多个匹配的 Bot,无法安全选择,已跳过。", log_cmd) + return None + @classmethod def is_qbot(cls, session: Uninfo | Bot) -> bool: """判断bot是否为qq官bot @@ -437,10 +477,13 @@ class PlatformUtils: info = interface.basic_info() platform = info["scope"].lower() return "qq" if platform.startswith("qq") else platform + adapter_name = _adapter_name(t) + if "onebot" in adapter_name or adapter_name == "qq": + return "qq" + return adapter_name or "unknown" else: platform = t.basic["scope"].lower() return "qq" if platform.startswith("qq") else platform - return "unknown" @classmethod def get_platform_scope(cls, t: Bot | Uninfo | object) -> str: