zhenxun_bot/zhenxun/services/scheduler/manager.py
Rumio 70bde00757
feat(core): 增强定时任务与群组标签管理,重构调度核心 (#2068)
*  feat(core): 更新群组信息、Markdown 样式与 Pydantic 兼容层

- 【group】添加更新所有群组信息指令,并同步群组控制台数据
- 【markdown】支持合并 Markdown 的 CSS 来源
- 【pydantic-compat】提供 model_validate 兼容函数

*  feat(core): 增强定时任务与群组标签管理,重构调度核心

 新功能

* **标签 (tags)**: 引入群组标签服务。
    * 支持静态标签和动态标签 (基于 Alconna 规则自动匹配群信息)。
    * 支持黑名单模式及 `@all` 特殊标签。
    * 提供 `tag_manage` 超级用户插件 (list, create, edit, delete 等)。
    * 群成员变动时自动失效动态标签缓存。
* **调度 (scheduler)**: 增强定时任务。
    * 重构 `ScheduledJob` 模型,支持 `TAG`, `ALL_GROUPS` 等多种目标类型。
    * 新增任务别名 (`name`)、创建者、权限、来源等字段。
    * 支持一次性任务 (`schedule_once`) 和 Alconna 命令行参数 (`--params-cli`)。
    * 新增执行选项 (`jitter`, `spread`) 和并发策略 (`ALLOW`, `SKIP`, `QUEUE`)。
    * 支持批量获取任务状态。

♻️ 重构优化

* **调度器核心**:
    * 拆分 `service.py` 为 `manager.py` (API) 和 `types.py` (模型)。
    * 合并 `adapter.py` / `job.py` 至 `engine.py` (统一调度引擎)。
    * 引入 `targeting.py` 模块管理任务目标解析。
* **调度器插件 (scheduler_admin)**:
    * 迁移命令参数校验逻辑至 `ArparmaBehavior`。
    * 引入 `dependencies.py` 和 `data_source.py` 解耦业务逻辑与依赖注入。
    * 适配新的任务目标类型展示。

* 🐛 fix(tag): 修复黑名单标签解析逻辑并优化标签详情展示

*  feat(scheduler): 为多目标定时任务添加固定间隔串行执行选项

*  feat(schedulerAdmin): 允许定时任务删除、暂停、恢复命令支持多ID操作

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-03 10:53:40 +08:00

667 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
服务层 (Service Manager)
定义 SchedulerManager 类作为定时任务服务的公共 API 入口。
它负责编排业务逻辑,并调用 Repository 和 Adapter 层来完成具体工作。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from datetime import datetime
import inspect
from typing import Any, ClassVar
import uuid
from arclet.alconna import Alconna, Option
import nonebot
from nonebot.adapters import Bot
from pydantic import BaseModel
from zhenxun.configs.config import Config
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump, model_validate
from .engine import APSchedulerAdapter
from .repository import ScheduleRepository
from .targeting import (
ScheduleTargeter,
)
from .types import (
BaseTrigger,
EphemeralJobDeclaration,
ExecutionOptions,
ExecutionPolicy,
ScheduleContext,
ScheduledJobDeclaration,
)
class SchedulerManager:
ALL_GROUPS: ClassVar[str] = "__ALL_GROUPS__"
_registered_tasks: ClassVar[
dict[
str,
dict[str, Callable | type[BaseModel] | int | list[Option] | Alconna | None],
]
] = {}
_declared_tasks: ClassVar[list[ScheduledJobDeclaration]] = []
_ephemeral_declared_tasks: ClassVar[list[EphemeralJobDeclaration]] = []
_running_tasks: ClassVar[set] = set()
_target_resolvers: ClassVar[
dict[str, Callable[[str, Bot], Awaitable[list[str | None]]]]
] = {}
def __init__(self):
self._register_builtin_resolvers()
def _register_builtin_resolvers(self):
"""在管理器初始化时注册所有内置的目标解析器。"""
from .targeting import (
_resolve_all_groups,
_resolve_global_or_user,
_resolve_group,
_resolve_tag,
_resolve_user,
)
if "GROUP" in self._target_resolvers:
return
self.register_target_resolver("GROUP", _resolve_group)
self.register_target_resolver("TAG", _resolve_tag)
self.register_target_resolver("ALL_GROUPS", _resolve_all_groups)
self.register_target_resolver("GLOBAL", _resolve_global_or_user)
self.register_target_resolver("USER", _resolve_user)
logger.debug("已注册所有内置的定时任务目标解析器。")
def register_target_resolver(
self,
target_type: str,
resolver_func: Callable[[str, Bot], Awaitable[list[str | None]]],
):
"""
注册一个新的目标类型解析器。
"""
if target_type in self._target_resolvers:
logger.warning(f"目标解析器 '{target_type}' 已存在,将被覆盖。")
self._target_resolvers[target_type.upper()] = resolver_func
logger.info(f"已注册新的定时任务目标解析器: '{target_type}'")
def target(self, **filters: Any) -> ScheduleTargeter:
"""
创建目标选择器以执行批量操作
参数:
**filters: 过滤条件支持plugin_name、group_id、bot_id等字段。
返回:
ScheduleTargeter: 目标选择器对象,可用于批量操作。
"""
return ScheduleTargeter(self, **filters)
def job(
self,
trigger: BaseTrigger,
group_id: str | None = None,
bot_id: str | None = None,
default_params: BaseModel | None = None,
policy: ExecutionPolicy | None = None,
default_jitter: int | None = None,
default_spread: int | None = None,
default_interval: int | None = None,
):
"""
声明式定时任务的统一装饰器。
"""
def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]:
try:
plugin = nonebot.get_plugin_by_module_name(func.__module__)
if not plugin:
raise ValueError(f"函数 {func.__name__} 不在任何已加载的插件中。")
plugin_name = plugin.name
params_model = None
from .types import ScheduleContext
for param in inspect.signature(func).parameters.values():
if (
isinstance(param.annotation, type)
and issubclass(param.annotation, BaseModel)
and param.annotation is not ScheduleContext
):
params_model = param.annotation
break
if plugin_name in self._registered_tasks:
logger.warning(f"插件 '{plugin_name}' 的定时任务已被重复注册。")
self._registered_tasks[plugin_name] = {
"func": func,
"model": params_model,
"default_jitter": default_jitter,
"default_spread": default_spread,
"default_interval": default_interval,
}
job_kwargs = model_dump(default_params) if default_params else {}
if policy:
job_kwargs["execution_policy"] = model_dump(policy)
task_declaration = ScheduledJobDeclaration(
plugin_name=plugin_name,
group_id=group_id,
bot_id=bot_id,
trigger=trigger,
job_kwargs=job_kwargs,
)
self._declared_tasks.append(task_declaration)
logger.debug(
f"发现声明式定时任务 '{plugin_name}',将在启动时进行注册。"
)
except Exception as e:
logger.error(f"注册声明式定时任务失败: {func.__name__}, 错误: {e}")
return func
return decorator
def runtime_job(self, trigger: BaseTrigger):
"""
声明一个临时的、非持久化的定时任务。
"""
def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]:
try:
plugin = nonebot.get_plugin_by_module_name(func.__module__)
if not plugin:
raise ValueError(f"函数 {func.__name__} 不在任何已加载的插件中。")
plugin_name = plugin.name
self._registered_tasks[f"ephemeral::{plugin_name}::{func.__name__}"] = {
"func": func,
"model": None,
}
declaration = EphemeralJobDeclaration(
plugin_name=plugin_name,
func=func,
trigger=trigger,
)
self._ephemeral_declared_tasks.append(declaration)
logger.debug(
f"发现临时定时任务 '{plugin_name}:{func.__name__}',将在启动时调度"
)
except Exception as e:
logger.error(f"注册临时定时任务失败: {func.__name__}, 错误: {e}")
return func
return decorator
def register(
self,
plugin_name: str,
params_model: type[BaseModel] | None = None,
cli_parser: Alconna | None = None,
default_permission: int = 5,
default_jitter: int | None = None,
default_spread: int | None = None,
default_interval: int | None = None,
) -> Callable:
"""
注册可调度的任务函数
"""
def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]:
if plugin_name in self._registered_tasks:
logger.warning(f"插件 '{plugin_name}' 的定时任务已被重复注册。")
self._registered_tasks[plugin_name] = {
"func": func,
"model": params_model,
"cli_parser": cli_parser,
"default_permission": default_permission,
"default_jitter": default_jitter,
"default_spread": default_spread,
"default_interval": default_interval,
}
model_name = params_model.__name__ if params_model else ""
logger.debug(
f"插件 '{plugin_name}' 的定时任务已注册,参数模型: {model_name}"
)
return func
return decorator
def get_registered_plugins(self) -> list[str]:
"""
获取已注册插件列表
"""
return list(self._registered_tasks.keys())
async def run_at(self, func: Callable[..., Coroutine], trigger: BaseTrigger) -> str:
"""
在未来的某个时间点,运行一个一次性的临时任务。
"""
job_id = f"ephemeral_runtime_{uuid.uuid4()}"
context = ScheduleContext(
schedule_id=0,
plugin_name=f"runtime::{func.__module__}",
bot_id=None,
group_id=None,
job_kwargs={},
)
APSchedulerAdapter.add_ephemeral_job(
job_id=job_id,
func=func,
trigger_type=trigger.trigger_type,
trigger_config=model_dump(trigger, exclude={"trigger_type"}),
context=context,
)
logger.info(f"已动态调度一个临时任务 (ID: {job_id}),将在 {trigger} 触发。")
return job_id
async def schedule_once(
self,
func: Callable[..., Coroutine],
trigger: BaseTrigger,
*,
user_id: str | None = None,
group_id: str | None = None,
bot_id: str | None = None,
job_kwargs: dict | None = None,
name: str | None = None,
created_by: str | None = None,
required_permission: int = 5,
) -> "ScheduledJob | None":
"""
编程式API用于动态调度一个持久化的、一次性的任务。
"""
if user_id and group_id:
raise ValueError("user_id 和 group_id 不能同时提供。")
temp_plugin_name = f"runtime_one_off__{func.__module__}.{func.__name__}__{uuid.uuid4().hex[:8]}" # noqa: E501
self._registered_tasks[temp_plugin_name] = {"func": func, "model": None}
logger.debug(f"为一次性任务动态注册临时插件: '{temp_plugin_name}'")
target_type = "USER" if user_id else ("GROUP" if group_id else "GLOBAL")
target_identifier = user_id or group_id or ""
return await self.add_schedule(
plugin_name=temp_plugin_name,
target_type=target_type,
target_identifier=target_identifier,
trigger_type=trigger.trigger_type,
trigger_config=model_dump(trigger, exclude={"trigger_type"}),
job_kwargs=job_kwargs,
bot_id=bot_id,
name=name,
created_by=created_by,
required_permission=required_permission,
is_one_off=True,
)
async def add_daily_task(
self,
plugin_name: str,
group_id: str | None,
hour: int,
minute: int,
second: int = 0,
job_kwargs: dict | None = None,
bot_id: str | None = None,
) -> "ScheduledJob | None":
"""
添加每日定时任务
"""
trigger_config = {
"hour": hour,
"minute": minute,
"second": second,
"timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"),
}
return await self.add_schedule(
plugin_name,
target_type="GROUP" if group_id else "GLOBAL",
target_identifier=group_id or "",
trigger_type="cron",
trigger_config=trigger_config,
job_kwargs=job_kwargs,
bot_id=bot_id,
)
async def add_interval_task(
self,
plugin_name: str,
group_id: str | None,
*,
weeks: int = 0,
days: int = 0,
hours: int = 0,
minutes: int = 0,
seconds: int = 0,
start_date: str | datetime | None = None,
job_kwargs: dict | None = None,
bot_id: str | None = None,
) -> "ScheduledJob | None":
"""
添加间隔性定时任务
"""
trigger_config = {
"weeks": weeks,
"days": days,
"hours": hours,
"minutes": minutes,
"seconds": seconds,
"start_date": start_date,
}
trigger_config = {k: v for k, v in trigger_config.items() if v}
return await self.add_schedule(
plugin_name,
target_type="GROUP" if group_id else "GLOBAL",
target_identifier=group_id or "",
trigger_type="interval",
trigger_config=trigger_config,
job_kwargs=job_kwargs,
bot_id=bot_id,
)
def _validate_and_prepare_kwargs(
self, plugin_name: str, job_kwargs: dict | None
) -> tuple[bool, str | dict]:
"""验证并准备任务参数,应用默认值"""
from pydantic import ValidationError
task_meta = self._registered_tasks.get(plugin_name)
if not task_meta:
return False, f"插件 '{plugin_name}' 未注册。"
params_model = task_meta.get("model")
job_kwargs = job_kwargs if job_kwargs is not None else {}
if not params_model:
if job_kwargs:
logger.warning(
f"插件 '{plugin_name}' 未定义参数模型,但收到了参数: {job_kwargs}"
)
return True, job_kwargs
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
logger.error(f"插件 '{plugin_name}' 的参数模型不是有效的 BaseModel 类")
return False, f"插件 '{plugin_name}' 的参数模型配置错误"
try:
validated_model = model_validate(params_model, job_kwargs)
return True, model_dump(validated_model)
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
error_str = "\n".join(errors)
msg = f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}"
return False, msg
async def add_schedule(
self,
plugin_name: str,
target_type: str,
target_identifier: str,
trigger_type: str,
trigger_config: dict,
job_kwargs: dict | None = None,
bot_id: str | None = None,
*,
name: str | None = None,
created_by: str | None = None,
required_permission: int = 5,
source: str = "USER",
is_one_off: bool = False,
execution_options: dict | None = None,
) -> "ScheduledJob | None":
"""
添加定时任务(通用方法)
参数:
plugin_name: 插件名称。
target_type: 目标类型 (GROUP, USER, TAG, ALL_GROUPS, GLOBAL)。
target_identifier: 目标标识符。
trigger_type: 触发器类型 (cron, interval, date)。
trigger_config: 触发器配置字典。
job_kwargs: 传递给任务函数的额外参数。
bot_id: Bot ID约束。
name: 任务别名。
created_by: 创建者ID。
required_permission: 管理此任务所需的权限。
source: 任务来源 (USER, PLUGIN_DEFAULT)。
is_one_off: 是否为一次性任务。
execution_options: 任务执行的额外选项 (例如: jitter, spread)。
返回:
ScheduledJob | None: 创建的任务信息失败时返回None。
"""
if plugin_name not in self._registered_tasks:
logger.error(f"插件 '{plugin_name}' 没有注册可用的定时任务。")
return None
is_valid, result = self._validate_and_prepare_kwargs(plugin_name, job_kwargs)
if not is_valid:
logger.error(f"任务参数校验失败: {result}")
return None
options_dict = execution_options or {}
validated_options = ExecutionOptions(**options_dict)
search_kwargs = {
"plugin_name": plugin_name,
"target_type": target_type,
"target_identifier": target_identifier,
}
if bot_id:
search_kwargs["bot_id"] = bot_id
defaults = {
"name": name,
"trigger_type": trigger_type,
"trigger_config": trigger_config,
"job_kwargs": result,
"is_enabled": True,
"created_by": created_by,
"required_permission": required_permission,
"source": source,
"is_one_off": is_one_off,
"execution_options": model_dump(validated_options, exclude_none=True),
}
defaults = {k: v for k, v in defaults.items() if v is not None}
schedule, created = await ScheduleRepository.update_or_create(
defaults, **search_kwargs
)
APSchedulerAdapter.add_or_reschedule_job(schedule)
action_str = "创建" if created else "更新"
logger.info(
f"已成功{action_str}任务 '{name or plugin_name}' (ID: {schedule.id})"
)
return schedule
async def get_schedules(
self, page: int | None = None, page_size: int | None = None, **filters: Any
) -> tuple[list[ScheduledJob], int]:
"""
根据条件获取定时任务列表
"""
cleaned_filters = {k: v for k, v in filters.items() if v is not None}
return await ScheduleRepository.query_schedules(
page=page, page_size=page_size, **cleaned_filters
)
async def get_schedules_status_bulk(
self, schedule_ids: list[int]
) -> list[dict[str, Any]]:
"""
批量获取多个定时任务的详细状态信息
"""
if not schedule_ids:
return []
schedules = await ScheduleRepository.filter(id__in=schedule_ids).all()
schedule_map = {s.id: s for s in schedules}
statuses = []
for schedule_id in schedule_ids:
if schedule := schedule_map.get(schedule_id):
status_from_scheduler = APSchedulerAdapter.get_job_status(schedule.id)
status_dict = {
field: getattr(schedule, field)
for field in schedule._meta.fields_map
}
status_dict.update(status_from_scheduler)
status_dict["is_enabled"] = (
"运行中"
if schedule_id in self._running_tasks
else ("启用" if schedule.is_enabled else "暂停")
)
statuses.append(status_dict)
return statuses
async def update_schedule(
self,
schedule_id: int,
trigger_type: str | None = None,
trigger_config: dict | None = None,
job_kwargs: dict | None = None,
) -> tuple[bool, str]:
"""
更新定时任务配置
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule:
return False, f"未找到 ID 为 {schedule_id} 的任务。"
updated_fields = []
if trigger_config is not None:
schedule.trigger_config = trigger_config
updated_fields.append("trigger_config")
if trigger_type is not None and schedule.trigger_type != trigger_type:
schedule.trigger_type = trigger_type
updated_fields.append("trigger_type")
if job_kwargs is not None:
existing_kwargs = (
schedule.job_kwargs.copy()
if isinstance(schedule.job_kwargs, dict)
else {}
)
existing_kwargs.update(job_kwargs)
is_valid, result = self._validate_and_prepare_kwargs(
schedule.plugin_name, existing_kwargs
)
if not is_valid:
return False, str(result)
assert isinstance(result, dict), "验证成功时 result 应该是字典类型"
schedule.job_kwargs = result
updated_fields.append("job_kwargs")
if not updated_fields:
return True, "没有任何需要更新的配置。"
await ScheduleRepository.save(schedule, update_fields=updated_fields)
APSchedulerAdapter.add_or_reschedule_job(schedule)
return True, f"成功更新了任务 ID: {schedule_id} 的配置。"
async def get_schedule_status(self, schedule_id: int) -> dict | None:
"""
获取定时任务的详细状态信息
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule:
return None
status_from_scheduler = APSchedulerAdapter.get_job_status(schedule.id)
status_text = (
"运行中"
if schedule_id in self._running_tasks
else ("启用" if schedule.is_enabled else "暂停")
)
return {
"id": schedule.id,
"bot_id": schedule.bot_id,
"plugin_name": schedule.plugin_name,
"target_type": schedule.target_type,
"target_identifier": schedule.target_identifier,
"is_enabled": status_text,
"trigger_type": schedule.trigger_type,
"trigger_config": schedule.trigger_config,
"job_kwargs": schedule.job_kwargs,
**status_from_scheduler,
}
async def pause_schedule(self, schedule_id: int) -> tuple[bool, str]:
"""
暂停指定的定时任务
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule or not schedule.is_enabled:
return False, "任务不存在或已暂停。"
schedule.is_enabled = False
await ScheduleRepository.save(schedule, update_fields=["is_enabled"])
APSchedulerAdapter.pause_job(schedule_id)
return True, f"已暂停任务 (ID: {schedule.id})。"
async def resume_schedule(self, schedule_id: int) -> tuple[bool, str]:
"""
恢复指定的定时任务
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule or schedule.is_enabled:
return False, "任务不存在或已启用。"
schedule.is_enabled = True
await ScheduleRepository.save(schedule, update_fields=["is_enabled"])
APSchedulerAdapter.resume_job(schedule_id)
return True, f"已恢复任务 (ID: {schedule.id})。"
async def trigger_now(self, schedule_id: int) -> tuple[bool, str]:
"""
立即手动触发指定的定时任务
"""
from .engine import _execute_job
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule:
return False, f"未找到 ID 为 {schedule_id} 的定时任务。"
if schedule.plugin_name not in self._registered_tasks:
return False, f"插件 '{schedule.plugin_name}' 没有注册可用的定时任务。"
try:
await _execute_job(schedule.id, force=True)
return True, f"已手动触发任务 (ID: {schedule.id})。"
except Exception as e:
logger.error(f"手动触发任务失败: {e}")
return False, f"手动触发任务失败: {e}"
async def get_schedule_by_id(self, schedule_id: int) -> "ScheduledJob | None":
"""
通过ID获取任务对象的公共方法。
参数:
schedule_id: 任务ID。
返回:
ScheduledJob | None: 任务对象不存在时返回None。
"""
return await ScheduleRepository.get_by_id(schedule_id)
scheduler_manager = SchedulerManager()