From 70bde0075739976895b39638f55846f062bdc77d Mon Sep 17 00:00:00 2001 From: Rumio <32546670+webjoin111@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:53:40 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(core):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E4=B8=8E=E7=BE=A4=E7=BB=84?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E7=AE=A1=E7=90=86=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E6=A0=B8=E5=BF=83=20(#2068)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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操作 * :rotating_light: 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> --- .../admin/group_member_update/__init__.py | 75 +++ .../admin/group_member_update/_data_source.py | 20 + .../scheduler_admin/__init__.py | 103 ++-- .../scheduler_admin/commands.py | 383 ++++++------- .../scheduler_admin/data_source.py | 314 +++++++++++ .../scheduler_admin/dependencies.py | 370 ++++++++++++ .../scheduler_admin/handlers.py | 492 ++++++---------- .../scheduler_admin/presenters.py | 68 +-- .../builtin_plugins/superuser/tag_manage.py | 413 ++++++++++++++ zhenxun/models/group_tag.py | 54 ++ zhenxun/models/scheduled_job.py | 56 +- zhenxun/services/__init__.py | 10 +- zhenxun/services/scheduler/__init__.py | 9 +- zhenxun/services/scheduler/adapter.py | 174 ------ zhenxun/services/scheduler/engine.py | 482 ++++++++++++++++ zhenxun/services/scheduler/job.py | 239 -------- zhenxun/services/scheduler/lifecycle.py | 14 +- .../scheduler/{service.py => manager.py} | 408 +++++++------- zhenxun/services/scheduler/repository.py | 24 +- .../scheduler/{targeter.py => targeting.py} | 55 +- zhenxun/services/scheduler/types.py | 151 +++++ zhenxun/services/tags/__init__.py | 11 + zhenxun/services/tags/filters.py | 11 + zhenxun/services/tags/manager.py | 527 ++++++++++++++++++ zhenxun/services/tags/models.py | 41 ++ zhenxun/ui/models/core/markdown.py | 11 +- zhenxun/utils/pydantic_compat.py | 11 + zhenxun/utils/time_utils.py | 32 +- 28 files changed, 3304 insertions(+), 1254 deletions(-) create mode 100644 zhenxun/builtin_plugins/scheduler_admin/data_source.py create mode 100644 zhenxun/builtin_plugins/scheduler_admin/dependencies.py create mode 100644 zhenxun/builtin_plugins/superuser/tag_manage.py create mode 100644 zhenxun/models/group_tag.py delete mode 100644 zhenxun/services/scheduler/adapter.py create mode 100644 zhenxun/services/scheduler/engine.py delete mode 100644 zhenxun/services/scheduler/job.py rename zhenxun/services/scheduler/{service.py => manager.py} (64%) rename zhenxun/services/scheduler/{targeter.py => targeting.py} (69%) create mode 100644 zhenxun/services/scheduler/types.py create mode 100644 zhenxun/services/tags/__init__.py create mode 100644 zhenxun/services/tags/filters.py create mode 100644 zhenxun/services/tags/manager.py create mode 100644 zhenxun/services/tags/models.py diff --git a/zhenxun/builtin_plugins/admin/group_member_update/__init__.py b/zhenxun/builtin_plugins/admin/group_member_update/__init__.py index 7d9ab573..d8fb1070 100644 --- a/zhenxun/builtin_plugins/admin/group_member_update/__init__.py +++ b/zhenxun/builtin_plugins/admin/group_member_update/__init__.py @@ -1,7 +1,11 @@ +import asyncio +import random + import nonebot from nonebot import on_notice from nonebot.adapters import Bot from nonebot.adapters.onebot.v11 import GroupIncreaseNoticeEvent +from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata from nonebot_plugin_alconna import Alconna, Arparma, on_alconna from nonebot_plugin_apscheduler import scheduler @@ -10,6 +14,7 @@ from nonebot_plugin_session import EventSession from zhenxun.configs.config import BotConfig from zhenxun.configs.utils import PluginExtraData from zhenxun.services.log import logger +from zhenxun.services.tags import tag_manager from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils from zhenxun.utils.platform import PlatformUtils @@ -45,12 +50,79 @@ _matcher = on_alconna( _notice = on_notice(priority=1, block=False, rule=notice_rule(GroupIncreaseNoticeEvent)) +_update_all_matcher = on_alconna( + Alconna("更新所有群组信息"), + permission=SUPERUSER, + priority=1, + block=True, +) + + +async def _update_all_groups_task(bot: Bot, session: EventSession): + """ + 在后台执行所有群组的更新任务,并向超级用户发送最终报告。 + """ + success_count = 0 + fail_count = 0 + total_count = 0 + bot_id = bot.self_id + + logger.info(f"Bot {bot_id}: 开始执行所有群组信息更新任务...", "更新所有群组") + try: + group_list, _ = await PlatformUtils.get_group_list(bot) + total_count = len(group_list) + for i, group in enumerate(group_list): + try: + logger.debug( + f"Bot {bot_id}: 正在更新第 {i + 1}/{total_count} 个群组: " + f"{group.group_id}", + "更新所有群组", + ) + await MemberUpdateManage.update_group_member(bot, group.group_id) + success_count += 1 + except Exception as e: + fail_count += 1 + logger.error( + f"Bot {bot_id}: 更新群组 {group.group_id} 信息失败", + "更新所有群组", + e=e, + ) + await asyncio.sleep(random.uniform(1.5, 3.0)) + except Exception as e: + logger.error(f"Bot {bot_id}: 获取群组列表失败,任务中断", "更新所有群组", e=e) + await PlatformUtils.send_superuser( + bot, + f"Bot {bot_id} 更新所有群组信息任务失败:无法获取群组列表。", + session.id1, + ) + return + + await tag_manager._invalidate_cache() + summary_message = ( + f"🤖 Bot {bot_id} 所有群组信息更新任务完成!\n" + f"总计群组: {total_count}\n" + f"✅ 成功: {success_count}\n" + f"❌ 失败: {fail_count}" + ) + logger.info(summary_message.replace("\n", " | "), "更新所有群组") + await PlatformUtils.send_superuser(bot, summary_message, session.id1) + + +@_update_all_matcher.handle() +async def _(bot: Bot, session: EventSession): + await MessageUtils.build_message( + "已开始在后台更新所有群组信息,过程可能需要几分钟到几十分钟,完成后将私聊通知您。" + ).send(reply_to=True) + asyncio.create_task(_update_all_groups_task(bot, session)) # noqa: RUF006 + + @_matcher.handle() async def _(bot: Bot, session: EventSession, arparma: Arparma): if gid := session.id3 or session.id2: logger.info("更新群组成员信息", arparma.header_result, session=session) result = await MemberUpdateManage.update_group_member(bot, gid) await MessageUtils.build_message(result).finish(reply_to=True) + await tag_manager._invalidate_cache() await MessageUtils.build_message("群组id为空...").send() @@ -64,6 +136,7 @@ async def _(bot: Bot, event: GroupIncreaseNoticeEvent): session=event.user_id, group_id=event.group_id, ) + await tag_manager._invalidate_cache() @scheduler.scheduled_job( @@ -91,3 +164,5 @@ async def _(): except Exception as e: logger.error(f"Bot: {bot.self_id} 自动更新群组信息", e=e) logger.debug(f"自动 Bot: {bot.self_id} 更新群组成员信息成功...") + + await tag_manager._invalidate_cache() diff --git a/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py b/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py index 977cad35..39bcee29 100644 --- a/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py +++ b/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py @@ -6,6 +6,7 @@ from nonebot.adapters import Bot from nonebot_plugin_uninfo import Member, SceneType, get_interface from zhenxun.configs.config import Config +from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.level_user import LevelUser from zhenxun.services.log import logger @@ -94,6 +95,25 @@ class MemberUpdateManage: ) return "更新群组失败,群组不存在..." members = await interface.get_members(SceneType.GROUP, group_list[0].id) + + try: + group_console, _ = await GroupConsole.get_or_create( + group_id=group_id, defaults={"platform": platform} + ) + group_console.member_count = len(members) + group_console.group_name = group_list[0].name or "" + await group_console.save(update_fields=["member_count", "group_name"]) + logger.debug( + f"已更新群组 {group_id} 的成员总数为 {len(members)}", + "更新群组成员信息", + ) + except Exception as e: + logger.error( + f"更新群组 {group_id} 的 GroupConsole 信息失败", + "更新群组成员信息", + e=e, + ) + db_user = await GroupInfoUser.filter(group_id=group_id).all() db_user_uid = [u.user_id for u in db_user] data_list = ([], [], []) diff --git a/zhenxun/builtin_plugins/scheduler_admin/__init__.py b/zhenxun/builtin_plugins/scheduler_admin/__init__.py index eb71bafb..989aa75c 100644 --- a/zhenxun/builtin_plugins/scheduler_admin/__init__.py +++ b/zhenxun/builtin_plugins/scheduler_admin/__init__.py @@ -10,47 +10,54 @@ __all__ = ["commands", "handlers"] __plugin_meta__ = PluginMetadata( name="定时任务管理", description="查看和管理由 SchedulerManager 控制的定时任务。", - usage=""" -📋 定时任务管理 - 支持群聊和私聊操作 + usage="""### 📋 定时任务管理 +--- +#### 🔍 **查看任务** +- **命令**: `定时任务 查看 [选项]` (别名: `ls`, `list`) +- **选项**: + - `--all`: 查看所有群组的任务 **(SUPERUSER)**。 + - `-g <群号>`: 查看指定群组的任务 **(SUPERUSER)**。 + - `-p <插件名>`: 按插件名筛选。 + - `--page <页码>`: 指定页码。 +- **说明**: + - 在群聊中不带选项使用,默认查看本群任务。 + - 在私聊中必须使用 `-g <群号>` 或 `--all`。 -🔍 查看任务: - 定时任务 查看 [-all] [-g <群号>] [-p <插件>] [--page <页码>] - • 群聊中: 查看本群任务 - • 私聊中: 必须使用 -g <群号> 或 -all 选项 (SUPERUSER) +#### 📊 **任务状态** +- **命令**: `定时任务 状态 <任务ID>` (别名: `status`, `info`, `任务状态`) +- **说明**: 查看单个任务的详细信息和状态。 -📊 任务状态: - 定时任务 状态 <任务ID> 或 任务状态 <任务ID> - • 查看单个任务的详细信息和状态 +#### ⚙️ **任务管理 (SUPERUSER)** +- **设置**: `定时任务 设置 <插件>` (别名: `add`, `开启`) + - **选项**: + - `<时间选项>`: 详见下文。 + - `-g <群号|all>`: 指定目标群组。 + - `--kwargs "<参数>"`: 设置任务参数 (例: `"key=value"`)。 +- **删除**: `定时任务 删除 ` (别名: `del`, `rm`, `remove`, `关闭`, `取消`) +- **暂停**: `定时任务 暂停 ` (别名: `pause`) +- **恢复**: `定时任务 恢复 ` (别名: `resume`) +- **执行**: `定时任务 执行 ` (别名: `trigger`, `run`) +- **更新**: `定时任务 更新 ` (别名: `update`, `modify`, `修改`) + - **选项**: + - `<时间选项>`: 详见下文。 + - `--kwargs "<参数>"`: 更新任务参数。 + - **批量操作**: `删除/暂停/恢复` 命令支持通过 `-p <插件名>` 或 `--all` + (当前群) 进行批量操作。 -⚙️ 任务管理 (SUPERUSER): - 定时任务 设置 <插件> [时间选项] [-g <群号> | -g all] [--kwargs <参数>] - 定时任务 删除 <任务ID> | -p <插件> [-g <群号>] | -all - 定时任务 暂停 <任务ID> | -p <插件> [-g <群号>] | -all - 定时任务 恢复 <任务ID> | -p <插件> [-g <群号>] | -all - 定时任务 执行 <任务ID> - 定时任务 更新 <任务ID> [时间选项] [--kwargs <参数>] - # [修改] 增加说明 - • 说明: -p 选项可单独使用,用于操作指定插件的所有任务 +#### 📝 **时间选项 (设置/更新时三选一)** +- `--cron "<分> <时> <日> <月> <周>"` (例: `--cron "0 8 * * *"`) +- `--interval <时间间隔>` (例: `--interval 30m`, `2h`, `10s`) +- `--date ""` (例: `--date "2024-01-01 08:00:00"`) +- `--daily ""` (例: `--daily "08:30"`) -📝 时间选项 (三选一): - --cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *" - --interval <时间间隔> # 例: --interval 30m, 2h, 10s - --date "" # 例: --date "2024-01-01 08:00:00" - --daily "" # 例: --daily "08:30" - -📚 其他功能: - 定时任务 插件列表 # 查看所有可设置定时任务的插件 (SUPERUSER) - -🏷️ 别名支持: - 查看: ls, list | 设置: add, 开启 | 删除: del, rm, remove, 关闭, 取消 - 暂停: pause | 恢复: resume | 执行: trigger, run | 状态: status, info - 更新: update, modify, 修改 | 插件列表: plugins +#### 📚 **其他功能** +- **命令**: `定时任务 插件列表` (别名: `plugins`) +- **说明**: 查看所有可设置定时任务的插件 **(SUPERUSER)**。 """.strip(), extra=PluginExtraData( author="HibiKier", version="0.1.2", plugin_type=PluginType.SUPERUSER, - is_show=False, configs=[ RegisterConfig( module="SchedulerManager", @@ -80,6 +87,38 @@ __plugin_meta__ = PluginMetadata( help="定时任务使用的时区,默认为 Asia/Shanghai", type=str, ), + RegisterConfig( + module="SchedulerManager", + key="SCHEDULE_ADMIN_LEVEL", + value=5, + help="设置'定时任务'系列命令的基础使用权限等级", + default_value=5, + type=int, + ), + RegisterConfig( + module="SchedulerManager", + key="DEFAULT_JITTER_SECONDS", + value=60, + help="为多目标定时任务(如 --all, -t)设置的默认触发抖动秒数,避免所有任务同时启动。", # noqa: E501 + default_value=60, + type=int, + ), + RegisterConfig( + module="SchedulerManager", + key="DEFAULT_SPREAD_SECONDS", + value=300, + help="为多目标定时任务设置的默认执行分散秒数,将任务执行分散在一个时间窗口内。", + default_value=300, + type=int, + ), + RegisterConfig( + module="SchedulerManager", + key="DEFAULT_INTERVAL_SECONDS", + value=0, + help="为多目标定时任务设置的默认串行执行间隔秒数(大于0时生效),用于控制任务间的固定时间间隔。", + default_value=0, + type=int, + ), ], ).to_dict(), ) diff --git a/zhenxun/builtin_plugins/scheduler_admin/commands.py b/zhenxun/builtin_plugins/scheduler_admin/commands.py index 8a565dab..6391cc85 100644 --- a/zhenxun/builtin_plugins/scheduler_admin/commands.py +++ b/zhenxun/builtin_plugins/scheduler_admin/commands.py @@ -1,33 +1,101 @@ -import re - -from nonebot.adapters import Event -from nonebot.adapters.onebot.v11 import Bot -from nonebot.params import Depends -from nonebot.permission import SUPERUSER +from arclet.alconna import ArparmaBehavior from nonebot_plugin_alconna import ( Alconna, - AlconnaMatch, Args, - Match, + Arparma, + Field, + MultiVar, Option, - Query, Subcommand, on_alconna, + store_true, ) -from zhenxun.configs.config import Config -from zhenxun.services.scheduler import scheduler_manager -from zhenxun.services.scheduler.targeter import ScheduleTargeter from zhenxun.utils.rules import admin_check + +def create_time_options() -> list[Option]: + """创建一组用于定义任务执行时间的通用选项""" + return [ + Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), + Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), + Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), + Option( + "--daily", + Args["daily_expr", str], + help_text="设置每天执行的时间 (如 08:20)", + ), + ] + + +def create_targeting_options() -> list[Option]: + """创建一组用于定位定时任务的通用选项""" + return [ + Option("-p", Args["plugin_name", str], help_text="按插件名筛选"), + Option("-u", Args["user_id", str], help_text="指定用户ID"), + Option( + "-g", + Args["group_ids", MultiVar(str)], + help_text="指定一个或多个群组ID (SUPERUSER)", + ), + Option("-t", Args["tag_name", str], help_text="指定标签"), + Option("--all", action=store_true, help_text="对所有群生效"), + Option("--global", action=store_true, help_text="操作全局任务"), + Option("--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"), + ] + + +class SchedulerAdminBehavior(ArparmaBehavior): + """对定时任务命令的参数进行复杂的复合验证。""" + + def _validate_time_options(self, interface: Arparma, subcommand: str): + """验证时间选项 (--cron, --interval, --date, --daily) 的互斥性。""" + time_options = ["cron", "interval", "date", "daily"] + provided_options = [ + f"--{opt}" for opt in time_options if interface.query(f"{subcommand}.{opt}") + ] + if len(provided_options) > 1: + interface.behave_fail( + f"时间选项 {', '.join(provided_options)} 不能同时使用,请只选择一个。" + ) + + def _validate_target_options(self, interface: Arparma, subcommand: str): + """验证目标选项 (-u, -g, -t, --all, --global) 的互斥性。""" + target_flags = { + "-u": "u", + "-g": "g", + "-t": "t", + "--all": "all", + "--global": "global", + } + provided_flags = [ + flag + for flag, name in target_flags.items() + if interface.query(f"{subcommand}.{name}") + ] + + if len(provided_flags) > 1: + interface.behave_fail( + f"目标选项 {', '.join(provided_flags)} 是互斥的,请只选择一个。" + ) + + def operate(self, interface: Arparma): + subcommand = next(iter(interface.subcommands.keys()), None) + if not subcommand: + return + + if subcommand in {"设置", "更新"}: + self._validate_time_options(interface, subcommand) + if subcommand in {"查看", "设置", "删除", "暂停", "恢复"}: + self._validate_target_options(interface, subcommand) + + schedule_cmd = on_alconna( Alconna( "定时任务", Subcommand( "查看", - Option("-g", Args["target_group_id", str]), - Option("-all", help_text="查看所有群聊 (SUPERUSER)"), - Option("-p", Args["plugin_name", str], help_text="按插件名筛选"), + *create_targeting_options(), Option("--page", Args["page", int, 1], help_text="指定页码"), alias=["ls", "list"], help_text="查看定时任务", @@ -35,17 +103,41 @@ schedule_cmd = on_alconna( Subcommand( "设置", Args["plugin_name", str], - Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), - Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), - Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), + *create_time_options(), Option( - "--daily", - Args["daily_expr", str], - help_text="设置每天执行的时间 (如 08:20)", + "-g", Args["group_ids", MultiVar(str)], help_text="指定一个或多个群组ID" ), - Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"), - Option("-all", help_text="对所有群生效 (等同于 -g all)"), + Option("-u", Args["user_id", str], help_text="指定用户ID"), + Option("-t", Args["tag_name", str], help_text="指定一个群组标签"), + Option("--all", action=store_true, help_text="对所有群生效"), + Option("--global", action=store_true, help_text="设置为全局任务"), + Option("--name", Args["job_name", str], help_text="为任务设置一个别名"), Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"), + Option( + "--params-cli", + Args["cli_string", str], + help_text="传递给插件任务的原始命令行参数字符串", + ), + Option( + "--jitter", + Args["jitter_seconds", int], + help_text="设置触发时间抖动(秒)", + ), + Option( + "--spread", + Args["spread_seconds", int], + help_text="设置多目标执行的分散延迟(秒)", + ), + Option( + "--fixed-interval", + Args["interval_seconds", int], + help_text="设置任务间的固定执行间隔(秒),将强制串行", + ), + Option( + "--permission", + Args["perm_level", int], + help_text="设置任务的管理权限等级", + ), Option( "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" ), @@ -54,64 +146,75 @@ schedule_cmd = on_alconna( ), Subcommand( "删除", - Args["schedule_id?", int], - Option("-p", Args["plugin_name", str], help_text="指定插件名"), - Option("-g", Args["group_id", str], help_text="指定群组ID"), - Option("-all", help_text="对所有群生效"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), + Args[ + "schedule_ids?", + MultiVar(int), + Field(unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!"), + ], + *create_targeting_options(), alias=["del", "rm", "remove", "关闭", "取消"], help_text="删除一个或多个定时任务", ), Subcommand( "暂停", - Args["schedule_id?", int], - Option("-all", help_text="对当前群所有任务生效"), - Option("-p", Args["plugin_name", str], help_text="指定插件名"), - Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), + Args[ + "schedule_ids?", + MultiVar(int), + Field(unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!"), + ], + *create_targeting_options(), alias=["pause"], help_text="暂停一个或多个定时任务", ), Subcommand( "恢复", - Args["schedule_id?", int], - Option("-all", help_text="对当前群所有任务生效"), - Option("-p", Args["plugin_name", str], help_text="指定插件名"), - Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"), - Option( - "--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)" - ), + Args[ + "schedule_ids?", + MultiVar(int), + Field(unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!"), + ], + *create_targeting_options(), alias=["resume"], help_text="恢复一个或多个定时任务", ), Subcommand( "执行", - Args["schedule_id", int], + Args[ + "schedule_id", + int, + Field( + missing_tips=lambda: "请提供要立即执行的任务ID!", + unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!", + ), + ], alias=["trigger", "run"], help_text="立即执行一次任务", ), Subcommand( "更新", - Args["schedule_id", int], - Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"), - Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"), - Option("--date", Args["date_expr", str], help_text="设置特定执行日期"), - Option( - "--daily", - Args["daily_expr", str], - help_text="更新每天执行的时间 (如 08:20)", - ), + Args[ + "schedule_id", + int, + Field( + missing_tips=lambda: "请提供要更新的任务ID!", + unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!", + ), + ], + *create_time_options(), Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"), alias=["update", "modify", "修改"], help_text="更新任务配置", ), Subcommand( "状态", - Args["schedule_id", int], + Args[ + "schedule_id", + int, + Field( + missing_tips=lambda: "请提供要查看状态的任务ID!", + unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!", + ), + ], alias=["status", "info"], help_text="查看单个任务的详细状态", ), @@ -120,179 +223,19 @@ schedule_cmd = on_alconna( alias=["plugins"], help_text="列出所有可用的插件", ), + behaviors=[SchedulerAdminBehavior()], ), priority=5, block=True, - rule=admin_check(1), + skip_for_unmatch=False, + aliases={"schedule", "cron", "job"}, + rule=admin_check("SchedulerManager", "SCHEDULE_ADMIN_LEVEL"), ) + schedule_cmd.shortcut( "任务状态", command="定时任务", arguments=["状态", "{%0}"], prefix=True, ) - - -class ScheduleTarget: - pass - - -class TargetByID(ScheduleTarget): - def __init__(self, id: int): - self.id = id - - -class TargetByPlugin(ScheduleTarget): - def __init__( - self, plugin: str, group_id: str | None = None, all_groups: bool = False - ): - self.plugin = plugin - self.group_id = group_id - self.all_groups = all_groups - - -class TargetAll(ScheduleTarget): - def __init__(self, for_group: str | None = None): - self.for_group = for_group - - -TargetScope = TargetByID | TargetByPlugin | TargetAll | None - - -def create_target_parser(subcommand_name: str): - async def dependency( - event: Event, - schedule_id: Match[int] = AlconnaMatch("schedule_id"), - plugin_name: Match[str] = AlconnaMatch("plugin_name"), - group_id: Match[str] = AlconnaMatch("group_id"), - all_enabled: Query[bool] = Query(f"{subcommand_name}.all"), - ) -> TargetScope: - if schedule_id.available: - return TargetByID(schedule_id.result) - - if plugin_name.available: - p_name = plugin_name.result - if all_enabled.available: - return TargetByPlugin(plugin=p_name, all_groups=True) - elif group_id.available: - gid = group_id.result - if gid.lower() == "all": - return TargetByPlugin(plugin=p_name, all_groups=True) - return TargetByPlugin(plugin=p_name, group_id=gid) - else: - current_group_id = getattr(event, "group_id", None) - return TargetByPlugin( - plugin=p_name, - group_id=str(current_group_id) if current_group_id else None, - ) - - if all_enabled.available: - current_group_id = getattr(event, "group_id", None) - if not current_group_id: - await schedule_cmd.finish( - "私聊中单独使用 -all 选项时,必须使用 -g <群号> 指定目标。" - ) - return TargetAll(for_group=str(current_group_id)) - - return None - - return dependency - - -def parse_interval(interval_str: str) -> dict: - match = re.match(r"(\d+)([smhd])", interval_str.lower()) - if not match: - raise ValueError("时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。") - value, unit = int(match.group(1)), match.group(2) - if unit == "s": - return {"seconds": value} - if unit == "m": - return {"minutes": value} - if unit == "h": - return {"hours": value} - if unit == "d": - return {"days": value} - return {} - - -def parse_daily_time(time_str: str) -> dict: - if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str): - hour, minute, second = match.groups() - hour, minute = int(hour), int(minute) - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise ValueError("小时或分钟数值超出范围。") - cron_config = { - "minute": str(minute), - "hour": str(hour), - "day": "*", - "month": "*", - "day_of_week": "*", - "timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"), - } - if second is not None: - if not (0 <= int(second) <= 59): - raise ValueError("秒数值超出范围。") - cron_config["second"] = str(second) - return cron_config - else: - raise ValueError("时间格式错误,请使用 'HH:MM' 或 'HH:MM:SS' 格式。") - - -async def GetBotId(bot: Bot, bot_id_match: Match[str] = AlconnaMatch("bot_id")) -> str: - if bot_id_match.available: - return bot_id_match.result - return bot.self_id - - -def GetTargeter(subcommand: str): - """ - 依赖注入函数,用于解析命令参数并返回一个配置好的 ScheduleTargeter 实例。 - """ - - async def dependency( - event: Event, - bot: Bot, - schedule_id: Match[int] = AlconnaMatch("schedule_id"), - plugin_name: Match[str] = AlconnaMatch("plugin_name"), - group_id: Match[str] = AlconnaMatch("group_id"), - all_enabled: Query[bool] = Query(f"{subcommand}.all"), - bot_id_to_operate: str = Depends(GetBotId), - ) -> ScheduleTargeter: - if schedule_id.available: - return scheduler_manager.target(id=schedule_id.result) - - if plugin_name.available: - if all_enabled.available: - return scheduler_manager.target(plugin_name=plugin_name.result) - - current_group_id = getattr(event, "group_id", None) - gid = group_id.result if group_id.available else current_group_id - return scheduler_manager.target( - plugin_name=plugin_name.result, - group_id=str(gid) if gid else None, - bot_id=bot_id_to_operate, - ) - - if all_enabled.available: - current_group_id = getattr(event, "group_id", None) - gid = group_id.result if group_id.available else current_group_id - is_su = await SUPERUSER(bot, event) - if not gid and not is_su: - await schedule_cmd.finish( - f"在私聊中对所有任务进行'{subcommand}'操作需要超级用户权限。" - ) - - if (gid and str(gid).lower() == "all") or (not gid and is_su): - return scheduler_manager.target() - - return scheduler_manager.target( - group_id=str(gid) if gid else None, bot_id=bot_id_to_operate - ) - - await schedule_cmd.finish( - f"'{subcommand}'操作失败:请提供任务ID," - f"或通过 -p <插件名> 或 -all 指定要操作的任务。" - ) - - return Depends(dependency) diff --git a/zhenxun/builtin_plugins/scheduler_admin/data_source.py b/zhenxun/builtin_plugins/scheduler_admin/data_source.py new file mode 100644 index 00000000..bdb4b659 --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler_admin/data_source.py @@ -0,0 +1,314 @@ +from typing import Any + +from pydantic import BaseModel, ValidationError + +from zhenxun.models.level_user import LevelUser +from zhenxun.models.scheduled_job import ScheduledJob +from zhenxun.services import scheduler_manager +from zhenxun.services.log import logger +from zhenxun.services.scheduler.repository import ScheduleRepository +from zhenxun.utils.pydantic_compat import model_dump, model_validate + +from . import presenters + + +class SchedulerAdminService: + """封装定时任务管理的所有业务逻辑""" + + async def get_schedules_view( + self, + user_id: str, + group_id: str | None, + is_superuser: bool, + filters: dict[str, Any], + page: int, + ) -> bytes | str: + """获取任务列表视图""" + page_size = 30 + schedules, total_items = await scheduler_manager.get_schedules( + page=page, page_size=page_size, **filters + ) + + if not schedules: + return "没有找到任何相关的定时任务。" + + permitted_schedules = schedules + skipped_count = 0 + if not is_superuser: + permitted_schedules, skipped_count = await self._filter_schedules_for_user( + schedules, user_id, group_id + ) + + if not permitted_schedules: + return ( + f"您没有权限查看任何匹配的任务。(因权限不足跳过 {skipped_count} 个)" + ) + + title = self._generate_view_title(filters) + + return await presenters.format_schedule_list_as_image( + schedules=permitted_schedules, + title=title, + current_page=page, + total_items=total_items, + ) + + async def set_schedule( + self, + targets: list[str], + creator_permission_level: int, + plugin_name: str, + trigger_info: tuple[str, dict], + job_kwargs: dict, + permission: int, + bot_id: str, + job_name: str | None, + jitter: int | None, + spread: int | None, + interval: int | None, + created_by: str, + ) -> str: + """创建或更新一个定时任务""" + trigger_type, trigger_config = trigger_info + success_targets = [] + failed_targets = [] + permission_denied_targets = [] + execution_options = {} + if jitter is not None: + execution_options["jitter"] = jitter + if spread is not None: + execution_options["spread"] = spread + if interval is not None: + execution_options["interval"] = interval + + for target_desc in targets: + target_type, target_id = self._resolve_target_descriptor(target_desc) + + existing_schedule = await ScheduleRepository.filter( + plugin_name=plugin_name, + target_type=target_type, + target_identifier=target_id, + bot_id=bot_id, + ).first() + + if ( + existing_schedule + and creator_permission_level < existing_schedule.required_permission + ): + permission_denied_targets.append( + ( + target_desc, + f"需要 {existing_schedule.required_permission} 级权限", + ) + ) + continue + + if target_type in ["TAG", "ALL_GROUPS"]: + logger.debug( + f"检测到多目标任务 (类型: {target_type})," + f"将所需权限强制提升至超级用户级别。" + ) + permission = 9 + + try: + schedule = await scheduler_manager.add_schedule( + plugin_name=plugin_name, + target_type=target_type, + target_identifier=target_id, + trigger_type=trigger_type, + trigger_config=trigger_config, + job_kwargs=job_kwargs, + bot_id=bot_id, + required_permission=permission, + name=job_name, + created_by=created_by, + execution_options=execution_options if execution_options else None, + ) + if schedule: + success_targets.append((target_desc, schedule.id)) + else: + failed_targets.append((target_desc, "服务返回失败")) + except Exception as e: + failed_targets.append((target_desc, str(e))) + + return self._format_set_result_message( + targets, success_targets, failed_targets, permission_denied_targets + ) + + async def perform_bulk_operation( + self, + operation_name: str, + user_id: str, + group_id: str | None, + is_superuser: bool, + targeter, + all_flag: bool, + global_flag: bool, + ) -> str: + """执行批量操作(删除、暂停、恢复)""" + if not is_superuser: + permission_denied = False + if all_flag or global_flag: + permission_denied = True + elif targeter._filters.get("target_type") in ["TAG", "ALL_GROUPS"]: + permission_denied = True + + if permission_denied: + return "权限不足,只有超级用户才能对所有群组或通过标签进行批量操作。" + + schedules_to_operate = await targeter._get_schedules() + if not schedules_to_operate: + return "没有找到符合条件的可操作任务。" + + permitted_schedules, skipped_count = ( + (schedules_to_operate, 0) + if is_superuser + else await self._filter_schedules_for_user( + schedules_to_operate, user_id, group_id + ) + ) + + if not permitted_schedules: + return ( + f"您没有权限{operation_name}任何匹配的任务。" + f"(因权限不足跳过 {skipped_count} 个)" + ) + + permitted_ids = [s.id for s in permitted_schedules] + final_targeter = scheduler_manager.target(id__in=permitted_ids) + + operation_map = { + "删除": final_targeter.remove, + "暂停": final_targeter.pause, + "恢复": final_targeter.resume, + } + operation_func = operation_map.get(operation_name) + if not operation_func: + return f"未知的批量操作: {operation_name}" + + count, _ = await operation_func() + msg = f"批量{operation_name}操作完成:\n - 成功: {count} 个" + if skipped_count > 0: + msg += f"\n - 因权限不足跳过: {skipped_count} 个" + return msg + + async def trigger_schedule_now(self, schedule: ScheduledJob) -> str: + """立即触发一个任务""" + success, message = await scheduler_manager.trigger_now(schedule.id) + return ( + presenters.format_trigger_success(schedule) + if success + else f"❌ 触发失败: {message}" + ) + + async def update_schedule( + self, schedule: ScheduledJob, trigger_info: tuple | None, kwargs_str: str | None + ) -> str: + """更新一个任务的配置""" + trigger_type = trigger_info[0] if trigger_info else None + trigger_config = trigger_info[1] if trigger_info else None + job_kwargs = await self._parse_and_validate_kwargs_for_update( + schedule.plugin_name, kwargs_str + ) + success, message = await scheduler_manager.update_schedule( + schedule.id, trigger_type, trigger_config, job_kwargs + ) + if success: + updated_schedule = await scheduler_manager.get_schedule_by_id(schedule.id) + return ( + presenters.format_update_success(updated_schedule) + if updated_schedule + else "✅ 更新成功,但无法获取更新后的任务详情。" + ) + return f"❌ 更新失败: {message}" + + async def get_schedule_status(self, schedule_id: int) -> str: + """获取单个任务的状态""" + status = await scheduler_manager.get_schedule_status(schedule_id) + if not status: + return f"未找到ID为 {schedule_id} 的任务。" + return presenters.format_single_status_message(status) + + async def get_plugins_list(self) -> str: + """获取可定时执行的插件列表""" + return await presenters.format_plugins_list() + + async def _filter_schedules_for_user( + self, schedules: list[ScheduledJob], user_id: str, group_id: str | None + ) -> tuple[list[ScheduledJob], int]: + user_level = await LevelUser.get_user_level(user_id, group_id) + permitted = [s for s in schedules if user_level >= s.required_permission] + skipped_count = len(schedules) - len(permitted) + return permitted, skipped_count + + def _generate_view_title(self, filters: dict) -> str: + title = "定时任务" + if filters.get("target_type") == "ALL_GROUPS": + title = "全局定时任务" + elif "target_identifier" in filters: + title = f"群 {filters['target_identifier']} 的定时任务" + if "plugin_name" in filters: + title += f" [插件: {filters['plugin_name']}]" + return title + + def _resolve_target_descriptor(self, target_desc: str) -> tuple[str, str]: + if target_desc == scheduler_manager.ALL_GROUPS: + return "ALL_GROUPS", scheduler_manager.ALL_GROUPS + if target_desc.startswith("tag:"): + return "TAG", target_desc[4:] + if target_desc.isdigit(): + return "GROUP", target_desc + return "USER", target_desc + + def _format_set_result_message( + self, targets: list, success: list, failed: list, permission_denied: list + ) -> str: + msg = f"为 {len(targets)} 个目标设置/更新任务完成:\n" + if success: + msg += f"- 成功: {len(success)} 个" + ids_str = ", ".join(str(s[1]) for s in success) + msg += f"\n - ID列表: {ids_str}" + else: + msg += "- 成功: 0 个" + if permission_denied: + msg += f"\n- 因权限不足跳过: {len(permission_denied)} 个" + for target, reason in permission_denied: + msg += f"\n - 目标 {target}: {reason}" + if failed: + msg += f"\n- 失败: {len(failed)} 个" + for target, reason in failed: + msg += f"\n - 目标 {target}: {reason}" + return msg.strip() + + async def _parse_and_validate_kwargs_for_update( + self, plugin_name: str, kwargs_str: str | None + ) -> dict: + if not kwargs_str: + return {} + + task_meta = scheduler_manager._registered_tasks.get(plugin_name) + if not task_meta: + raise ValueError(f"插件 '{plugin_name}' 未注册。") + + params_model = task_meta.get("model") + if not ( + params_model + and isinstance(params_model, type) + and issubclass(params_model, BaseModel) + ): + raise ValueError(f"插件 '{plugin_name}' 不支持或配置了无效的参数模型。") + + try: + raw_kwargs = dict( + item.strip().split("=", 1) for item in kwargs_str.split(";") + ) + validated_model = model_validate(params_model, raw_kwargs) + return model_dump(validated_model) + except ValidationError as e: + errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] + raise ValueError("参数验证失败:\n" + "\n".join(errors)) + except Exception as e: + raise ValueError(f"参数格式错误: {e}") + + +scheduler_admin_service = SchedulerAdminService() diff --git a/zhenxun/builtin_plugins/scheduler_admin/dependencies.py b/zhenxun/builtin_plugins/scheduler_admin/dependencies.py new file mode 100644 index 00000000..1f1e1ac5 --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler_admin/dependencies.py @@ -0,0 +1,370 @@ +from datetime import datetime +import re +from typing import Any + +from arclet.alconna import Alconna +from nonebot.adapters import Bot, Event +from nonebot.params import Depends +from nonebot.permission import SUPERUSER +from nonebot_plugin_alconna import ( + AlconnaMatch, + AlconnaMatcher, + AlconnaMatches, + AlconnaQuery, + Arparma, + Match, + Query, +) +from nonebot_plugin_session import EventSession +from nonebot_plugin_uninfo import Uninfo + +from zhenxun.configs.config import Config +from zhenxun.models.level_user import LevelUser +from zhenxun.models.scheduled_job import ScheduledJob +from zhenxun.services import scheduler_manager +from zhenxun.utils.time_utils import TimeUtils + + +async def GetCreatorPermissionLevel( + bot: Bot, + event: Event, + session: Uninfo, +) -> int: + """ + 依赖注入函数:获取执行命令的用户的权限等级。 + """ + is_superuser = await SUPERUSER(bot, event) + if is_superuser: + return 999 + + current_group_id = session.group.id if session.group else None + return await LevelUser.get_user_level(session.user.id, current_group_id) + + +async def RequireTaskPermission( + matcher: AlconnaMatcher, + bot: Bot, + event: Event, + session: EventSession, + schedule_id_match: Match[int] = AlconnaMatch("schedule_id"), +) -> ScheduledJob: + """ + 依赖注入函数:获取并验证用户对特定任务的操作权限。 + """ + if not schedule_id_match.available: + await matcher.finish("此操作需要一个有效的任务ID。") + + schedule_id = schedule_id_match.result + schedule = await scheduler_manager.get_schedule_by_id(schedule_id) + if not schedule: + await matcher.finish(f"未找到ID为 {schedule_id} 的任务。") + + is_superuser = await SUPERUSER(bot, event) + if is_superuser: + return schedule + + user_id = session.id1 + if not user_id: + await matcher.finish("无法获取用户信息,权限检查失败。") + + group_id = session.id3 or session.id2 + user_level = await LevelUser.get_user_level(user_id, group_id) + + if user_level < schedule.required_permission: + await matcher.finish( + f"权限不足!操作此任务需要 {schedule.required_permission} 级权限," + f"您当前为 {user_level} 级。" + ) + + return schedule + + +def parse_daily_time(time_str: str) -> dict: + """解析每日时间字符串为 cron 配置字典""" + if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str): + hour, minute, second = match.groups() + hour, minute = int(hour), int(minute) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError("小时或分钟数值超出范围。") + cron_config = { + "minute": str(minute), + "hour": str(hour), + "day": "*", + "month": "*", + "day_of_week": "*", + "timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"), + } + if second is not None: + if not (0 <= int(second) <= 59): + raise ValueError("秒数值超出范围。") + cron_config["second"] = str(second) + return cron_config + else: + raise ValueError("时间格式错误,请使用 'HH:MM' 或 'HH:MM:SS' 格式。") + + +def _parse_trigger_from_arparma(arp: Arparma) -> tuple[str, dict] | None: + """从 Arparma 中解析时间触发器配置""" + subcommand_name = next(iter(arp.subcommands.keys()), None) + if not subcommand_name: + return None + + try: + if cron_expr := arp.query[str](f"{subcommand_name}.cron.cron_expr", None): + return "cron", dict( + zip( + ["minute", "hour", "day", "month", "day_of_week"], cron_expr.split() + ) + ) + if interval_expr := arp.query[str]( + f"{subcommand_name}.interval.interval_expr", None + ): + return "interval", TimeUtils.parse_interval_to_dict(interval_expr) + if date_expr := arp.query[str](f"{subcommand_name}.date.date_expr", None): + return "date", {"run_date": datetime.fromisoformat(date_expr)} + if daily_expr := arp.query[str](f"{subcommand_name}.daily.daily_expr", None): + return "cron", parse_daily_time(daily_expr) + except ValueError as e: + raise ValueError(f"时间参数解析错误: {e}") from e + return None + + +async def GetTriggerInfo( + matcher: AlconnaMatcher, + arp: Arparma = AlconnaMatches(), +) -> tuple[str, dict]: + """依赖注入函数:解析并验证时间触发器""" + try: + trigger_info = _parse_trigger_from_arparma(arp) + if trigger_info: + return trigger_info + except ValueError as e: + await matcher.finish(f"时间参数解析错误: {e}") + + await matcher.finish( + "必须提供一种时间选项: --cron, --interval, --date, 或 --daily。" + ) + + +async def GetBotId(bot: Bot, bot_id_match: Match[str] = AlconnaMatch("bot_id")) -> str: + """依赖注入函数:获取要操作的Bot ID""" + if bot_id_match.available: + return bot_id_match.result + return bot.self_id + + +async def GetTargeter( + matcher: AlconnaMatcher, + event: Event, + bot: Bot, + arp: Arparma = AlconnaMatches(), + schedule_ids: Match[list[int]] = AlconnaMatch("schedule_ids"), + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + group_ids: Match[list[str]] = AlconnaMatch("group_ids"), + user_id: Match[str] = AlconnaMatch("user_id"), + tag_name: Match[str] = AlconnaMatch("tag_name"), + bot_id_to_operate: str = Depends(GetBotId), +) -> Any: + """ + 依赖注入函数,用于解析命令参数并返回一个配置好的 ScheduleTargeter 实例。 + """ + subcommand = next(iter(arp.subcommands.keys()), None) + if not subcommand: + await matcher.finish("内部错误:无法解析子命令。") + + if schedule_ids.available: + return scheduler_manager.target(id__in=schedule_ids.result) + + all_enabled = arp.query(f"{subcommand}.all.value", False) + global_flag = arp.query(f"{subcommand}.global.value", False) + + if not any( + [ + plugin_name.available, + all_enabled, + global_flag, + user_id.available, + group_ids.available, + tag_name.available, + getattr(event, "group_id", None), + ] + ): + await matcher.finish( + f"'{subcommand}'操作失败:请提供任务ID," + f"或通过 -p <插件名> / --global / --all 指定要操作的任务。" + ) + + filters: dict[str, Any] = {"bot_id": bot_id_to_operate} + if plugin_name.available: + filters["plugin_name"] = plugin_name.result + + if global_flag: + filters["target_type"] = "ALL_GROUPS" + filters["target_identifier"] = scheduler_manager.ALL_GROUPS + elif user_id.available: + filters["target_type"] = "USER" + filters["target_identifier"] = user_id.result + elif all_enabled: + pass + elif tag_name.available: + filters["target_type"] = "TAG" + filters["target_identifier"] = tag_name.result + elif group_ids.available: + gids = [str(gid) for gid in group_ids.result] + filters["target_type"] = "GROUP" + filters["target_identifier__in"] = gids + else: + current_group_id = getattr(event, "group_id", None) + if current_group_id: + filters["target_type"] = "GROUP" + filters["target_identifier"] = str(current_group_id) + + return scheduler_manager.target(**filters) + + +async def GetValidatedJobKwargs( + matcher: AlconnaMatcher, + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + cli_string: Match[str] = AlconnaMatch("cli_string"), + kwargs_str: Match[str] = AlconnaMatch("kwargs_str"), +) -> dict: + """依赖注入函数:解析、合并和验证任务的关键字参数""" + p_name = plugin_name.result + task_meta = scheduler_manager._registered_tasks.get(p_name) + if not task_meta: + await matcher.finish(f"插件 '{p_name}' 未注册可定时执行的任务。") + + cli_kwargs = {} + if cli_string.available and cli_string.result.strip(): + if not (cli_parser := task_meta.get("cli_parser")): + await matcher.finish( + f"插件 '{p_name}' 不支持通过 --params-cli 设置参数," + f"因为它没有注册解析器。" + ) + + try: + temp_parser = Alconna("_", cli_parser.args, *cli_parser.options) # type: ignore + parsed_cli = temp_parser.parse(f"_ {cli_string.result.strip()}") + + if not parsed_cli.matched: + raise ValueError(f"参数无法匹配: {parsed_cli.error_info or '未知错误'}") + + cli_kwargs = parsed_cli.all_matched_args + + except Exception as e: + await matcher.finish( + f"使用 --params-cli 解析参数失败: {e}\n\n请确保参数格式与插件命令一致。" + ) + + explicit_kwargs = {} + if kwargs_str.available and kwargs_str.result.strip(): + try: + explicit_kwargs = dict( + item.strip().split("=", 1) + for item in kwargs_str.result.split(";") + if item.strip() + ) + except ValueError: + await matcher.finish( + "参数格式错误,--kwargs 请使用 'key=value;key2=value2' 格式。" + ) + + final_job_kwargs = {**cli_kwargs, **explicit_kwargs} + + is_valid, result = scheduler_manager._validate_and_prepare_kwargs( + p_name, final_job_kwargs + ) + if not is_valid: + await matcher.finish(f"任务参数校验失败:\n{result}") + + return result if isinstance(result, dict) else {} + + +async def GetFinalPermission( + matcher: AlconnaMatcher, + bot: Bot, + event: Event, + session: Uninfo, + plugin_name: Match[str] = AlconnaMatch("plugin_name"), + perm_level: Match[int] = AlconnaMatch("perm_level"), +) -> int: + """依赖注入函数:计算任务的最终权限等级""" + is_superuser = await SUPERUSER(bot, event) + current_group_id = session.group.id if session.group else None + + if is_superuser: + effective_user_level = 9 + else: + effective_user_level = await LevelUser.get_user_level( + session.user.id, current_group_id + ) + if perm_level.available: + requested_perm_level = perm_level.result + if not is_superuser and requested_perm_level > effective_user_level: + await matcher.send( + f"⚠️ 警告:您指定的权限等级 ({requested_perm_level}) " + f"高于自身权限 ({effective_user_level})。\n" + f"任务的管理权限已被自动设置为 {effective_user_level} 级。" + ) + return effective_user_level + return requested_perm_level + + else: + base_permission = effective_user_level + task_meta = scheduler_manager._registered_tasks.get(plugin_name.result) + if task_meta and "default_permission" in task_meta: + default_perm = task_meta.get("default_permission") + if isinstance(default_perm, int): + base_permission = default_perm + + return min(base_permission, effective_user_level) + + +async def ResolveTargets( + matcher: AlconnaMatcher, + bot: Bot, + event: Event, + session: Uninfo, + group_ids: Match[list[str]] = AlconnaMatch("group_ids"), + tag_name: Match[str] = AlconnaMatch("tag_name"), + user_id: Match[str] = AlconnaMatch("user_id"), + all_flag: Query[bool] = AlconnaQuery("设置.all.value", False), + global_flag: Query[bool] = AlconnaQuery("设置.global.value", False), +) -> list[str]: + """依赖注入函数,用于解析和计算最终的目标描述符列表,并进行权限检查""" + is_superuser = await SUPERUSER(bot, event) + current_group_id = session.group.id if session.group else None + + if not is_superuser: + permission_denied = False + if ( + global_flag.result + or all_flag.result + or tag_name.available + or user_id.available + ): + permission_denied = True + elif group_ids.available and any( + str(gid) != str(current_group_id) for gid in group_ids.result + ): + permission_denied = True + + if permission_denied: + await matcher.finish( + "权限不足,只有超级用户才能为其他群组、所有群组或通过标签设置任务。" + ) + + if user_id.available: + return [user_id.result] + if all_flag.result or global_flag.result: + return [scheduler_manager.ALL_GROUPS] + if tag_name.available: + return [f"tag:{tag_name.result}"] + if group_ids.available: + return group_ids.result + if current_group_id: + return [str(current_group_id)] + + await matcher.finish( + "私聊中设置任务必须使用 -u, -g, --all, --global 或 -t 选项指定目标。" + ) diff --git a/zhenxun/builtin_plugins/scheduler_admin/handlers.py b/zhenxun/builtin_plugins/scheduler_admin/handlers.py index 8bc54287..26b91a88 100644 --- a/zhenxun/builtin_plugins/scheduler_admin/handlers.py +++ b/zhenxun/builtin_plugins/scheduler_admin/handlers.py @@ -1,382 +1,238 @@ -from datetime import datetime +from typing import cast -from nonebot.adapters import Event -from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters import Bot, Event from nonebot.params import Depends from nonebot.permission import SUPERUSER -from nonebot_plugin_alconna import AlconnaMatch, Arparma, Match, Query -from pydantic import BaseModel, ValidationError - -from zhenxun.models.scheduled_job import ScheduledJob -from zhenxun.services.scheduler import scheduler_manager -from zhenxun.services.scheduler.targeter import ScheduleTargeter -from zhenxun.utils.message import MessageUtils -from zhenxun.utils.pydantic_compat import model_dump - -from . import presenters -from .commands import ( - GetBotId, - GetTargeter, - parse_daily_time, - parse_interval, - schedule_cmd, +from nonebot_plugin_alconna import ( + AlconnaMatch, + AlconnaMatches, + AlconnaQuery, + Arparma, + Match, + Query, ) +from nonebot_plugin_uninfo import Uninfo +from zhenxun.configs.config import Config +from zhenxun.models.scheduled_job import ScheduledJob +from zhenxun.services import scheduler_manager +from zhenxun.utils.message import MessageUtils -@schedule_cmd.handle() -async def _handle_time_options_mutex(arp: Arparma): - time_options = ["cron", "interval", "date", "daily"] - provided_options = [opt for opt in time_options if arp.query(opt) is not None] - if len(provided_options) > 1: - await schedule_cmd.finish( - f"时间选项 --{', --'.join(provided_options)} 不能同时使用,请只选择一个。" - ) +from .commands import schedule_cmd +from .data_source import scheduler_admin_service +from .dependencies import ( + GetBotId, + GetCreatorPermissionLevel, + GetFinalPermission, + GetTargeter, + GetTriggerInfo, + GetValidatedJobKwargs, + RequireTaskPermission, + ResolveTargets, + _parse_trigger_from_arparma, +) @schedule_cmd.assign("查看") async def handle_view( bot: Bot, event: Event, - target_group_id: Match[str] = AlconnaMatch("target_group_id"), - all_groups: Query[bool] = Query("查看.all"), - plugin_name: Match[str] = AlconnaMatch("plugin_name"), + session: Uninfo, page: Match[int] = AlconnaMatch("page"), + targeter=Depends(GetTargeter), ): + """处理 '查看' 子命令""" is_superuser = await SUPERUSER(bot, event) - title = "" - gid_filter = None + current_page = page.result if page.available else 1 - current_group_id = getattr(event, "group_id", None) - if not (all_groups.available or target_group_id.available) and not current_group_id: - await schedule_cmd.finish("私聊中查看任务必须使用 -g <群号> 或 -all 选项。") - - if all_groups.available: - if not is_superuser: - await schedule_cmd.finish("需要超级用户权限才能查看所有群组的定时任务。") - title = "所有群组的定时任务" - elif target_group_id.available: - if not is_superuser: - await schedule_cmd.finish("需要超级用户权限才能查看指定群组的定时任务。") - gid_filter = target_group_id.result - title = f"群 {gid_filter} 的定时任务" - else: - gid_filter = str(current_group_id) - title = "本群的定时任务" - - p_name_filter = plugin_name.result if plugin_name.available else None - - schedules = await scheduler_manager.get_schedules( - plugin_name=p_name_filter, group_id=gid_filter + result = await scheduler_admin_service.get_schedules_view( + user_id=session.user.id, + group_id=session.group.id if session.group else None, + is_superuser=is_superuser, + filters=targeter._filters, + page=current_page, ) - - if p_name_filter: - title += f" [插件: {p_name_filter}]" - - if not schedules: - await schedule_cmd.finish("没有找到任何相关的定时任务。") - - img = await presenters.format_schedule_list_as_image( - schedules=schedules, - title=title, - current_page=page.result if page.available else 1, - ) - await MessageUtils.build_message(img).send(reply_to=True) + await MessageUtils.build_message(result).send(reply_to=True) @schedule_cmd.assign("设置") async def handle_set( - event: Event, + session: Uninfo, + target_groups: list[str] = Depends(ResolveTargets), plugin_name: Match[str] = AlconnaMatch("plugin_name"), - cron_expr: Match[str] = AlconnaMatch("cron_expr"), - interval_expr: Match[str] = AlconnaMatch("interval_expr"), - date_expr: Match[str] = AlconnaMatch("date_expr"), - daily_expr: Match[str] = AlconnaMatch("daily_expr"), - group_id: Match[str] = AlconnaMatch("group_id"), - kwargs_str: Match[str] = AlconnaMatch("kwargs_str"), - all_enabled: Query[bool] = Query("设置.all"), + tag_name: Match[str] = AlconnaMatch("tag_name"), + jitter: Match[int] = AlconnaMatch("jitter_seconds"), + spread: Match[int] = AlconnaMatch("spread_seconds"), + interval: Match[int] = AlconnaMatch("interval_seconds"), + job_name: Match[str] = AlconnaMatch("job_name"), bot_id_to_operate: str = Depends(GetBotId), + trigger_info: tuple[str, dict] = Depends(GetTriggerInfo), + job_kwargs: dict = Depends(GetValidatedJobKwargs), + creator_permission_level: int = Depends(GetCreatorPermissionLevel), + final_permission: int = Depends(GetFinalPermission), ): - if not plugin_name.available: - await schedule_cmd.finish("设置任务时必须提供插件名称。") - - has_time_option = any( - [ - cron_expr.available, - interval_expr.available, - date_expr.available, - daily_expr.available, - ] - ) - if not has_time_option: - await schedule_cmd.finish( - "必须提供一种时间选项: --cron, --interval, --date, 或 --daily。" - ) - + """处理 '设置' 子命令""" p_name = plugin_name.result - if p_name not in scheduler_manager.get_registered_plugins(): - await schedule_cmd.finish( - f"插件 '{p_name}' 没有注册可用的定时任务。\n" - f"可用插件: {list(scheduler_manager.get_registered_plugins())}" + jitter_val: int | None = jitter.result if jitter.available else None + spread_val: int | None = spread.result if spread.available else None + interval_val: int | None = interval.result if interval.available else None + + is_multi_target = ( + len(target_groups) > 1 + or ( + len(target_groups) == 1 and target_groups[0] == scheduler_manager.ALL_GROUPS ) + or tag_name.available + ) - trigger_type, trigger_config = "", {} - try: - if cron_expr.available: - trigger_type, trigger_config = ( - "cron", - dict( - zip( - ["minute", "hour", "day", "month", "day_of_week"], - cron_expr.result.split(), - ) - ), - ) - elif interval_expr.available: - trigger_type, trigger_config = ( - "interval", - parse_interval(interval_expr.result), - ) - elif date_expr.available: - trigger_type, trigger_config = ( - "date", - {"run_date": datetime.fromisoformat(date_expr.result)}, - ) - elif daily_expr.available: - trigger_type, trigger_config = "cron", parse_daily_time(daily_expr.result) - else: - await schedule_cmd.finish( - "必须提供一种时间选项: --cron, --interval, --date, 或 --daily。" - ) - except ValueError as e: - await schedule_cmd.finish(f"时间参数解析错误: {e}") - - job_kwargs = {} - if kwargs_str.available: + if is_multi_target: task_meta = scheduler_manager._registered_tasks.get(p_name) - if not task_meta: - await schedule_cmd.finish(f"插件 '{p_name}' 未注册。") + if jitter_val is None: + if task_meta and task_meta.get("default_jitter") is not None: + jitter_val = cast(int | None, task_meta["default_jitter"]) + else: + jitter_val = Config.get_config( + "SchedulerManager", "DEFAULT_JITTER_SECONDS" + ) + if spread_val is None: + if task_meta and task_meta.get("default_spread") is not None: + spread_val = cast(int | None, task_meta["default_spread"]) + else: + spread_val = Config.get_config( + "SchedulerManager", "DEFAULT_SPREAD_SECONDS" + ) - params_model = task_meta.get("model") - if not ( - params_model - and isinstance(params_model, type) - and issubclass(params_model, BaseModel) - ): - await schedule_cmd.finish(f"插件 '{p_name}' 不支持或配置了无效的参数模型。") - try: - raw_kwargs = dict( - item.strip().split("=", 1) for item in kwargs_str.result.split(",") - ) + if interval_val is None: + if task_meta and task_meta.get("default_interval") is not None: + interval_val = cast(int | None, task_meta["default_interval"]) + else: + interval_val = Config.get_config( + "SchedulerManager", "DEFAULT_INTERVAL_SECONDS" + ) - model_validate = getattr(params_model, "model_validate", None) - if not model_validate: - await schedule_cmd.finish(f"插件 '{p_name}' 的参数模型不支持验证") - - validated_model = model_validate(raw_kwargs) - - job_kwargs = model_dump(validated_model) - except ValidationError as e: - errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()] - await schedule_cmd.finish( - f"插件 '{p_name}' 的任务参数验证失败:\n" + "\n".join(errors) - ) - except Exception as e: - await schedule_cmd.finish( - f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}" - ) - - gid_str = group_id.result if group_id.available else None - target_group_id = ( - scheduler_manager.ALL_GROUPS - if (gid_str and gid_str.lower() == "all") or all_enabled.available - else gid_str or getattr(event, "group_id", None) - ) - if not target_group_id: - await schedule_cmd.finish( - "私聊中设置定时任务时,必须使用 -g <群号> 或 --all 选项指定目标。" - ) - - schedule = await scheduler_manager.add_schedule( - p_name, - str(target_group_id), - trigger_type, - trigger_config, - job_kwargs, + result_message = await scheduler_admin_service.set_schedule( + targets=target_groups, + creator_permission_level=creator_permission_level, + plugin_name=p_name, + trigger_info=trigger_info, + job_kwargs=job_kwargs, + permission=final_permission, bot_id=bot_id_to_operate, + job_name=job_name.result if job_name.available else None, + jitter=jitter_val, + spread=spread_val, + interval=interval_val, + created_by=session.user.id, ) - - target_desc = ( - f"所有群组 (Bot: {bot_id_to_operate})" - if target_group_id == scheduler_manager.ALL_GROUPS - else f"群组 {target_group_id}" - ) - - if schedule: - await schedule_cmd.finish( - f"为 [{target_desc}] 已成功设置插件 '{p_name}' 的定时任务 " - f"(ID: {schedule.id})。" - ) - else: - await schedule_cmd.finish(f"为 [{target_desc}] 设置任务失败。") + await MessageUtils.build_message(result_message).send() @schedule_cmd.assign("删除") -async def handle_delete(targeter: ScheduleTargeter = GetTargeter("删除")): - schedules_to_remove: list[ScheduledJob] = await targeter._get_schedules() - if not schedules_to_remove: - await schedule_cmd.finish("没有找到可删除的任务。") - - count, _ = await targeter.remove() - - if count > 0 and schedules_to_remove: - if len(schedules_to_remove) == 1: - message = presenters.format_remove_success(schedules_to_remove[0]) - else: - target_desc = targeter._generate_target_description() - message = f"✅ 成功移除了{target_desc} {count} 个任务。" - else: - message = "没有任务被移除。" - await schedule_cmd.finish(message) +async def handle_delete( + bot: Bot, + event: Event, + session: Uninfo, + targeter=Depends(GetTargeter), + all_flag: Query[bool] = AlconnaQuery("删除.all.value", False), + global_flag: Query[bool] = AlconnaQuery("删除.global.value", False), +): + """处理 '删除' 子命令""" + is_superuser = await SUPERUSER(bot, event) + result_message = await scheduler_admin_service.perform_bulk_operation( + operation_name="删除", + user_id=session.user.id, + group_id=session.group.id if session.group else None, + is_superuser=is_superuser, + targeter=targeter, + all_flag=all_flag.result, + global_flag=global_flag.result, + ) + await schedule_cmd.finish(result_message) @schedule_cmd.assign("暂停") -async def handle_pause(targeter: ScheduleTargeter = GetTargeter("暂停")): - schedules_to_pause: list[ScheduledJob] = await targeter._get_schedules() - if not schedules_to_pause: - await schedule_cmd.finish("没有找到可暂停的任务。") - - count, _ = await targeter.pause() - - if count > 0 and schedules_to_pause: - if len(schedules_to_pause) == 1: - message = presenters.format_pause_success(schedules_to_pause[0]) - else: - target_desc = targeter._generate_target_description() - message = f"✅ 成功暂停了{target_desc} {count} 个任务。" - else: - message = "没有任务被暂停。" - await schedule_cmd.finish(message) +async def handle_pause( + bot: Bot, + event: Event, + session: Uninfo, + targeter=Depends(GetTargeter), + all_flag: Query[bool] = AlconnaQuery("暂停.all.value", False), + global_flag: Query[bool] = AlconnaQuery("暂停.global.value", False), +): + """处理 '暂停' 子命令""" + is_superuser = await SUPERUSER(bot, event) + result_message = await scheduler_admin_service.perform_bulk_operation( + operation_name="暂停", + user_id=session.user.id, + group_id=session.group.id if session.group else None, + is_superuser=is_superuser, + targeter=targeter, + all_flag=all_flag.result, + global_flag=global_flag.result, + ) + await schedule_cmd.finish(result_message) @schedule_cmd.assign("恢复") -async def handle_resume(targeter: ScheduleTargeter = GetTargeter("恢复")): - schedules_to_resume: list[ScheduledJob] = await targeter._get_schedules() - if not schedules_to_resume: - await schedule_cmd.finish("没有找到可恢复的任务。") - - count, _ = await targeter.resume() - - if count > 0 and schedules_to_resume: - if len(schedules_to_resume) == 1: - message = presenters.format_resume_success(schedules_to_resume[0]) - else: - target_desc = targeter._generate_target_description() - message = f"✅ 成功恢复了{target_desc} {count} 个任务。" - else: - message = "没有任务被恢复。" - await schedule_cmd.finish(message) +async def handle_resume( + bot: Bot, + event: Event, + session: Uninfo, + targeter=Depends(GetTargeter), + all_flag: Query[bool] = AlconnaQuery("恢复.all.value", False), + global_flag: Query[bool] = AlconnaQuery("恢复.global.value", False), +): + """处理 '恢复' 子命令""" + is_superuser = await SUPERUSER(bot, event) + result_message = await scheduler_admin_service.perform_bulk_operation( + operation_name="恢复", + user_id=session.user.id, + group_id=session.group.id if session.group else None, + is_superuser=is_superuser, + targeter=targeter, + all_flag=all_flag.result, + global_flag=global_flag.result, + ) + await schedule_cmd.finish(result_message) @schedule_cmd.assign("执行") -async def handle_trigger(schedule_id: Match[int] = AlconnaMatch("schedule_id")): - from zhenxun.services.scheduler.repository import ScheduleRepository - - schedule_info = await ScheduleRepository.get_by_id(schedule_id.result) - if not schedule_info: - await schedule_cmd.finish(f"未找到 ID 为 {schedule_id.result} 的任务。") - - success, message = await scheduler_manager.trigger_now(schedule_id.result) - - if success: - final_message = presenters.format_trigger_success(schedule_info) - else: - final_message = f"❌ 手动触发失败: {message}" - await schedule_cmd.finish(final_message) +async def handle_trigger(schedule: ScheduledJob = Depends(RequireTaskPermission)): + """处理 '执行' 子命令""" + result_message = await scheduler_admin_service.trigger_schedule_now(schedule) + await schedule_cmd.finish(result_message) @schedule_cmd.assign("更新") async def handle_update( - schedule_id: Match[int] = AlconnaMatch("schedule_id"), - cron_expr: Match[str] = AlconnaMatch("cron_expr"), - interval_expr: Match[str] = AlconnaMatch("interval_expr"), - date_expr: Match[str] = AlconnaMatch("date_expr"), - daily_expr: Match[str] = AlconnaMatch("daily_expr"), + schedule: ScheduledJob = Depends(RequireTaskPermission), + arp: Arparma = AlconnaMatches(), kwargs_str: Match[str] = AlconnaMatch("kwargs_str"), ): - if not any( - [ - cron_expr.available, - interval_expr.available, - date_expr.available, - daily_expr.available, - kwargs_str.available, - ] - ): + """处理 '更新' 子命令""" + trigger_info = _parse_trigger_from_arparma(arp) + if not trigger_info and not kwargs_str.available: await schedule_cmd.finish( "请提供需要更新的时间 (--cron/--interval/--date/--daily) 或参数 (--kwargs)" ) - trigger_type, trigger_config, job_kwargs = None, None, None - try: - if cron_expr.available: - trigger_type, trigger_config = ( - "cron", - dict( - zip( - ["minute", "hour", "day", "month", "day_of_week"], - cron_expr.result.split(), - ) - ), - ) - elif interval_expr.available: - trigger_type, trigger_config = ( - "interval", - parse_interval(interval_expr.result), - ) - elif date_expr.available: - trigger_type, trigger_config = ( - "date", - {"run_date": datetime.fromisoformat(date_expr.result)}, - ) - elif daily_expr.available: - trigger_type, trigger_config = "cron", parse_daily_time(daily_expr.result) - except ValueError as e: - await schedule_cmd.finish(f"时间参数解析错误: {e}") - - if kwargs_str.available: - job_kwargs = dict( - item.strip().split("=", 1) for item in kwargs_str.result.split(",") - ) - - success, message = await scheduler_manager.update_schedule( - schedule_id.result, trigger_type, trigger_config, job_kwargs + result_message = await scheduler_admin_service.update_schedule( + schedule, trigger_info, kwargs_str.result if kwargs_str.available else None ) - - if success: - from zhenxun.services.scheduler.repository import ScheduleRepository - - updated_schedule = await ScheduleRepository.get_by_id(schedule_id.result) - if updated_schedule: - final_message = presenters.format_update_success(updated_schedule) - else: - final_message = "✅ 更新成功,但无法获取更新后的任务详情。" - else: - final_message = f"❌ 更新失败: {message}" - - await schedule_cmd.finish(final_message) + await schedule_cmd.finish(result_message) @schedule_cmd.assign("插件列表") async def handle_plugins_list(): - message = await presenters.format_plugins_list() + """处理 '插件列表' 子命令""" + message = await scheduler_admin_service.get_plugins_list() await schedule_cmd.finish(message) @schedule_cmd.assign("状态") -async def handle_status(schedule_id: Match[int] = AlconnaMatch("schedule_id")): - status = await scheduler_manager.get_schedule_status(schedule_id.result) - if not status: - await schedule_cmd.finish(f"未找到ID为 {schedule_id.result} 的定时任务。") - - message = presenters.format_single_status_message(status) +async def handle_status( + schedule: ScheduledJob = Depends(RequireTaskPermission), +): + """处理 '状态' 子命令""" + message = await scheduler_admin_service.get_schedule_status(schedule.id) await schedule_cmd.finish(message) diff --git a/zhenxun/builtin_plugins/scheduler_admin/presenters.py b/zhenxun/builtin_plugins/scheduler_admin/presenters.py index 7005e348..33dddfaf 100644 --- a/zhenxun/builtin_plugins/scheduler_admin/presenters.py +++ b/zhenxun/builtin_plugins/scheduler_admin/presenters.py @@ -1,24 +1,13 @@ -import asyncio from typing import Any from zhenxun import ui from zhenxun.models.scheduled_job import ScheduledJob -from zhenxun.services.scheduler import scheduler_manager +from zhenxun.services import scheduler_manager from zhenxun.ui.builders import TableBuilder from zhenxun.ui.models import StatusBadgeCell, TextCell from zhenxun.utils.pydantic_compat import model_json_schema -def _get_type_name(annotation) -> str: - """获取类型注解的名称""" - if hasattr(annotation, "__name__"): - return annotation.__name__ - elif hasattr(annotation, "_name"): - return annotation._name - else: - return str(annotation) - - def _get_schedule_attr(schedule: ScheduledJob | dict, attr_name: str) -> Any: """兼容地从字典或对象获取属性""" if isinstance(schedule, dict): @@ -73,13 +62,8 @@ def _format_operation_result_card( schedule_info: 相关的 ScheduledJob 对象 extra_info: (可选) 额外的补充信息行 """ - target_desc = ( - f"群组 {schedule_info.group_id}" - if schedule_info.group_id - and schedule_info.group_id != scheduler_manager.ALL_GROUPS - else "所有群组" - if schedule_info.group_id == scheduler_manager.ALL_GROUPS - else "全局" + target_desc = format_target_info( + schedule_info.target_type, schedule_info.target_identifier ) info_lines = [ @@ -128,26 +112,22 @@ def _format_params(schedule_status: dict) -> str: async def format_schedule_list_as_image( - schedules: list[ScheduledJob], title: str, current_page: int + schedules: list[ScheduledJob], title: str, current_page: int, total_items: int ): """将任务列表格式化为图片""" - page_size = 15 - total_items = len(schedules) + page_size = 30 total_pages = (total_items + page_size - 1) // page_size - start_index = (current_page - 1) * page_size - end_index = start_index + page_size - paginated_schedules = schedules[start_index:end_index] - if not paginated_schedules: + if not schedules: return "这一页没有内容了哦~" - status_tasks = [ - scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules - ] - all_statuses = await asyncio.gather(*status_tasks) + schedule_ids = [s.id for s in schedules] + all_statuses_list = await scheduler_manager.get_schedules_status_bulk(schedule_ids) + all_statuses_map = {status["id"]: status for status in all_statuses_list} data_list = [] - for s in all_statuses: + for schedule_db in schedules: + s = all_statuses_map.get(schedule_db.id) if not s: continue @@ -166,7 +146,9 @@ async def format_schedule_list_as_image( TextCell(content=str(s["id"])), TextCell(content=s["plugin_name"]), TextCell(content=s.get("bot_id") or "N/A"), - TextCell(content=s["group_id"] or "全局"), + TextCell( + content=format_target_info(s["target_type"], s["target_identifier"]) + ), TextCell(content=s["next_run_time"]), TextCell(content=_format_trigger_info(s)), TextCell(content=_format_params(s)), @@ -190,17 +172,35 @@ async def format_schedule_list_as_image( ) +def format_target_info(target_type: str, target_identifier: str) -> str: + """格式化目标信息以供显示""" + if target_type == "GLOBAL": + return "全局" + elif target_type == "ALL_GROUPS": + return "所有群组" + elif target_type == "TAG": + return f"标签: {target_identifier}" + elif target_type == "GROUP": + return f"群: {target_identifier}" + elif target_type == "USER": + return f"用户: {target_identifier}" + else: + return f"{target_type}: {target_identifier}" + + def format_single_status_message(status: dict) -> str: """格式化单个任务状态为文本消息""" + target_info = format_target_info(status["target_type"], status["target_identifier"]) + trigger_info = status.get("trigger_info_str", _format_trigger_info(status)) info_lines = [ f"📋 定时任务详细信息 (ID: {status['id']})", "--------------------", f"▫️ 插件: {status['plugin_name']}", f"▫️ Bot ID: {status.get('bot_id') or '默认'}", - f"▫️ 目标: {status['group_id'] or '全局'}", + f"▫️ 目标: {target_info}", f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}", f"▫️ 下次运行: {status['next_run_time']}", - f"▫️ 触发规则: {_format_trigger_info(status)}", + f"▫️ 触发规则: {trigger_info}", f"▫️ 任务参数: {_format_params(status)}", ] return "\n".join(info_lines) diff --git a/zhenxun/builtin_plugins/superuser/tag_manage.py b/zhenxun/builtin_plugins/superuser/tag_manage.py new file mode 100644 index 00000000..f934531b --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/tag_manage.py @@ -0,0 +1,413 @@ +from nonebot.adapters import Bot +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import ( + Alconna, + AlconnaMatch, + AlconnaQuery, + Args, + Match, + MultiVar, + Option, + Query, + Subcommand, + on_alconna, + store_true, +) +from nonebot_plugin_waiter import prompt_until +from tortoise.exceptions import IntegrityError + +from zhenxun.configs.utils import PluginExtraData +from zhenxun.services.tags import tag_manager +from zhenxun.utils.enum import PluginType +from zhenxun.utils.message import MessageUtils + +__plugin_meta__ = PluginMetadata( + name="群组标签管理", + description="用于管理和操作群组标签", + usage="""### 🏷️ 群组标签管理 +用于创建和管理群组标签,以实现对群组的批量操作和筛选。 + +--- + +#### **✨ 核心命令** + +- **`tag list`** (别名: `ls`) + - 查看所有标签及其基本信息。 + +- **`tag info <标签名>`** + - 查看指定标签的详细信息,包括关联群组或动态规则的匹配结果。 + +- **`tag create <标签名> [选项...]`** + - 创建一个新标签。 + - **选项**: + - `--type `: 标签类型,默认为 `static`。 + - `static`: 静态标签,需手动关联群组。 + - `dynamic`: 动态标签,根据规则自动匹配。 + - `-g <群号...>`: **(静态)** 初始关联的群组ID。 + - `--rule "<规则>"`: **(动态)** 定义动态规则,**规则必须用引号包裹**。 + - `--desc "<描述>"`: 为标签添加描述。 + - `--blacklist`: **(静态)** 将标签设为黑名单(排除)模式。 + +- **`tag edit <标签名> [操作...]`** + - 编辑一个已存在的标签。 + - **通用操作**: + - `--rename <新名>`: 重命名标签。 + - `--desc "<描述>"`: 更新描述。 + - `--mode `: 切换为白名单/黑名单模式。 + - **静态标签操作**: + - `--add <群号...>`: 添加群组。 + - `--remove <群号...>`: 移除群组。 + - `--set <群号...>`: **[覆盖]** 重新设置所有关联群组。 + - **动态标签操作**: + - `--rule "<新规则>"`: 更新动态规则。 + +- **`tag delete <名1> [名2] ...`** + - 删除一个或多个标签。 + +- **`tag clear`** + - **[⚠️ 危险]** 删除所有标签,操作前会请求确认。 + +--- + +#### **🔧 动态规则速查** +规则支持 `and` 和 `or` 组合(`and` 优先)。 +**包含空格或特殊字符的规则值建议用英文引号包裹**。 + +- `member_count > 100` + 按 **群成员数** 筛选 (`>`, `>=`, `<`, `<=`, `=`)。 + +- `level >= 5` + 按 **群权限等级** 筛选。 + +- `status = true` + 按 **群是否休眠** 筛选 (`true` / `false`)。 + +- `is_super = false` + 按 **群是否为白名单** 筛选 (`true` / `false`)。 + +- `group_name contains "模式"` + 按 **群名模糊/正则匹配**。 + 例: `contains "测试.*群$"` 匹配以“测试”开头、“群”结尾的群名。 + +- `group_name in "群1,群2"` + 按 **群名多值精确匹配** (英文逗号分隔)。 + +--- + +#### **💡 使用示例** + +##### 静态标签示例 +```bash +# 创建一个名为“核心群”的静态标签,并关联两个群组 +tag create 核心群 -g 12345 67890 --desc "核心业务群" + +# 向“核心群”中添加一个新群组 +tag edit 核心群 --add 98765 + +# 创建一个用于排除的黑名单标签 +tag create 排除群 --blacklist -g 11111 +``` + +##### 动态标签示例 +```bash +# 创建一个动态标签,匹配所有成员数大于200的群 +tag create 大群 --type dynamic --rule "member_count > 200" + +# 创建一个匹配高权限且未休眠的群的标签 +tag create 活跃管理群 --type dynamic --rule "level > 5 and status = true" + +# 创建一个匹配群名包含“核心”或“测试”的标签 +tag create 业务群 --type dynamic --rule "group_name contains 核心 or group_name contains 测试" +``` + """.strip(), # noqa: E501 + extra=PluginExtraData( + author="HibiKier", + version="1.0.0", + plugin_type=PluginType.SUPERUSER, + ).to_dict(), +) +tag_cmd = on_alconna( + Alconna( + "tag", + Subcommand("list", alias=["ls"], help_text="查看所有标签"), + Subcommand("info", Args["name", str], help_text="查看标签详情"), + Subcommand( + "create", + Args["name", str], + Option( + "--rule", + Args["rule", str], + help_text="动态标签规则 (例如: min_members=100)", + ), + Option( + "--type", + Args["tag_type", ["static", "dynamic"]], + help_text="标签类型 (默认: static)", + ), + Option( + "--blacklist", action=store_true, help_text="设为黑名单模式(仅静态标签)" + ), + Option("--desc", Args["description", str], help_text="标签描述"), + Option( + "-g", Args["group_ids", MultiVar(str)], help_text="创建时要关联的群组ID" + ), + ), + Subcommand( + "edit", + Args["name", str], + Option( + "--rule", + Args["rule", str], + help_text="更新动态标签规则", + ), + Option("--add", Args["add_groups", MultiVar(str)]), + Option("--remove", Args["remove_groups", MultiVar(str)]), + Option("--set", Args["set_groups", MultiVar(str)]), + Option("--rename", Args["new_name", str]), + Option("--desc", Args["description", str]), + Option("--mode", Args["mode", ["black", "white"]]), + help_text="编辑标签", + ), + Subcommand( + "delete", + Args["names", MultiVar(str)], + alias=["del", "rm"], + help_text="删除标签", + ), + Subcommand("clear", help_text="清空所有标签"), + ), + permission=SUPERUSER, + priority=5, + block=True, +) + + +@tag_cmd.assign("list") +async def handle_list(): + tags = await tag_manager.list_tags_with_counts() + if not tags: + await MessageUtils.build_message("当前没有已创建的标签。").finish() + + msg = "已创建的群组标签:\n" + for tag in tags: + mode = "黑名单(排除)" if tag["is_blacklist"] else "白名单(包含)" + tag_type = "动态" if tag["tag_type"] == "DYNAMIC" else "静态" + count_desc = ( + f"含 {tag['group_count']} 个群组" if tag_type == "静态" else "动态计算" + ) + msg += f"- {tag['name']} (类型: {tag_type}, 模式: {mode}): {count_desc}\n" + await MessageUtils.build_message(msg).finish() + + +@tag_cmd.assign("info") +async def handle_info(name: Match[str], bot: Bot): + details = await tag_manager.get_tag_details(name.result, bot=bot) + if not details: + await MessageUtils.build_message(f"标签 '{name.result}' 不存在。").finish() + + mode = "黑名单(排除)" if details["is_blacklist"] else "白名单(包含)" + tag_type_str = "动态" if details["tag_type"] == "DYNAMIC" else "静态" + msg = f"标签详情: {details['name']}\n" + msg += f"类型: {tag_type_str}\n" + msg += f"模式: {mode}\n" + msg += f"描述: {details['description'] or '无'}\n" + + if details["tag_type"] == "STATIC" and details["is_blacklist"]: + msg += f"排除群组 ({len(details['groups'])}个):\n" + if details["groups"]: + msg += "\n".join(f"- {gid}" for gid in details["groups"]) + else: + msg += "无" + msg += "\n\n" + + if details["tag_type"] == "DYNAMIC" and details.get("dynamic_rule"): + msg += f"动态规则: {details['dynamic_rule']}\n" + + title = ( + "当前生效群组" + if details["tag_type"] == "DYNAMIC" or details["is_blacklist"] + else "关联群组" + ) + + if details["resolved_groups"] is not None: + msg += f"{title} ({len(details['resolved_groups'])}个):\n" + if details["resolved_groups"]: + msg += "\n".join( + f"- {g_name} ({g_id})" for g_id, g_name in details["resolved_groups"] + ) + else: + msg += "无" + else: + msg += f"关联群组 ({len(details['groups'])}个):\n" + if details["groups"]: + msg += "\n".join(f"- {gid}" for gid in details["groups"]) + else: + msg += "无" + + await MessageUtils.build_message(msg).finish() + + +@tag_cmd.assign("create") +async def handle_create( + name: Match[str], + description: Match[str], + group_ids: Match[list[str]], + rule: Match[str] = AlconnaMatch("rule"), + tag_type: Match[str] = AlconnaMatch("tag_type"), + blacklist: Query[bool] = AlconnaQuery("create.blacklist.value", False), +): + ttype = ( + tag_type.result.upper() + if tag_type.available + else ("DYNAMIC" if rule.available else "STATIC") + ) + + if ttype == "DYNAMIC" and not rule.available: + await MessageUtils.build_message( + "创建失败: 动态标签必须提供至少一个规则。" + ).finish() + + try: + tag = await tag_manager.create_tag( + name=name.result, + is_blacklist=blacklist.result, + description=description.result if description.available else None, + group_ids=group_ids.result if group_ids.available else None, + tag_type=ttype, + dynamic_rule=rule.result if rule.available else None, + ) + msg = f"标签 '{tag.name}' 创建成功!" + if group_ids.available: + msg += f"\n已同时关联 {len(group_ids.result)} 个群组。" + await MessageUtils.build_message(msg).finish() + except IntegrityError: + await MessageUtils.build_message( + f"创建失败: 标签 '{name.result}' 已存在。" + ).finish() + except ValueError as e: + await MessageUtils.build_message(f"创建失败: {e}").finish() + + +@tag_cmd.assign("edit") +async def handle_edit( + name: Match[str], + add_groups: Match[list[str]], + remove_groups: Match[list[str]], + set_groups: Match[list[str]], + new_name: Match[str], + description: Match[str], + mode: Match[str], + rule: Match[str] = AlconnaMatch("rule"), +): + tag_name = name.result + tag_details = await tag_manager.get_tag_details(tag_name) + if not tag_details: + await MessageUtils.build_message(f"标签 '{tag_name}' 不存在。").finish() + + group_actions = [ + add_groups.available, + remove_groups.available, + set_groups.available, + ] + if sum(group_actions) > 1: + await MessageUtils.build_message( + "`--add`, `--remove`, `--set` 选项不能同时使用。" + ).finish() + + is_dynamic = tag_details.get("tag_type") == "DYNAMIC" + + if is_dynamic and any(group_actions): + await MessageUtils.build_message( + "编辑失败: 不能对动态标签执行 --add, --remove, 或 --set 操作。" + ).finish() + + if not is_dynamic and rule.available: + await MessageUtils.build_message( + "编辑失败: 不能为静态标签设置动态规则。" + ).finish() + + results = [] + try: + rule_str = rule.result if rule.available else None + + if add_groups.available: + count = await tag_manager.add_groups_to_tag(tag_name, add_groups.result) + results.append(f"添加了 {count} 个群组。") + if remove_groups.available: + count = await tag_manager.remove_groups_from_tag( + tag_name, remove_groups.result + ) + results.append(f"移除了 {count} 个群组。") + if set_groups.available: + count = await tag_manager.set_groups_for_tag(tag_name, set_groups.result) + results.append(f"关联群组已覆盖为 {count} 个。") + + if description.available or mode.available or rule_str is not None: + is_blacklist = None + if mode.available: + is_blacklist = mode.result == "black" + await tag_manager.update_tag_attributes( + tag_name, + description.result if description.available else None, + is_blacklist, + rule_str, + ) + if rule_str is not None: + results.append(f"动态规则已更新为 '{rule_str}'。") + if description.available: + results.append("描述已更新。") + if mode.available: + results.append( + f"模式已更新为 {'黑名单' if is_blacklist else '白名单'}。" + ) + + if new_name.available: + await tag_manager.rename_tag(tag_name, new_name.result) + results.append(f"已重命名为 '{new_name.result}'。") + tag_name = new_name.result + + except (ValueError, IntegrityError) as e: + await MessageUtils.build_message(f"操作失败: {e}").finish() + + if not results: + await MessageUtils.build_message( + "未执行任何操作,请提供至少一个编辑选项。" + ).finish() + + final_msg = f"对标签 '{tag_name}' 的操作已完成:\n" + "\n".join( + f"- {r}" for r in results + ) + await MessageUtils.build_message(final_msg).finish() + + +@tag_cmd.assign("delete") +async def handle_delete(names: Match[list[str]]): + success, failed = [], [] + for name in names.result: + if await tag_manager.delete_tag(name): + success.append(name) + else: + failed.append(name) + msg = "" + if success: + msg += f"成功删除标签: {', '.join(success)}\n" + if failed: + msg += f"标签不存在,删除失败: {', '.join(failed)}" + await MessageUtils.build_message(msg.strip()).finish() + + +@tag_cmd.assign("clear") +async def handle_clear(): + confirm = await prompt_until( + "【警告】此操作将删除所有群组标签,是否继续?\n请输入 `是` 或 `确定` 确认操作", + lambda msg: msg.extract_plain_text().lower() + in ["是", "确定", "yes", "confirm"], + timeout=30, + retry=1, + ) + if confirm: + count = await tag_manager.clear_all_tags() + await MessageUtils.build_message(f"操作完成,已清空 {count} 个标签。").finish() + else: + await MessageUtils.build_message("操作已取消。").finish() diff --git a/zhenxun/models/group_tag.py b/zhenxun/models/group_tag.py new file mode 100644 index 00000000..6dec51ca --- /dev/null +++ b/zhenxun/models/group_tag.py @@ -0,0 +1,54 @@ +from tortoise import fields + +from zhenxun.services.db_context import Model + + +class GroupTag(Model): + """群组标签模型""" + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增ID""" + name = fields.CharField(max_length=255, unique=True, description="标签名称") + """标签名称""" + description = fields.TextField(null=True, description="标签描述") + """标签描述""" + owner_id = fields.CharField( + max_length=255, null=True, description="创建者ID, null为系统级" + ) + """创建此标签的用户ID""" + bot_id = fields.CharField( + max_length=255, null=True, description="所属Bot ID, null为全局通用" + ) + """此标签所属的Bot ID""" + tag_type = fields.CharField( + max_length=20, default="STATIC", description="标签类型 (STATIC, DYNAMIC)" + ) + """标签类型""" + dynamic_rule = fields.TextField(null=True, description="动态标签的计算规则") + """动态标签的计算规则""" + is_blacklist = fields.BooleanField(default=False, description="是否为黑名单模式") + """是否为黑名单模式 (True: 排除模式, False: 包含模式)""" + + groups: fields.ReverseRelation["GroupTagLink"] + + class Meta: # type: ignore + table = "group_tags" + table_description = "群组标签表" + + +class GroupTagLink(Model): + """群组与标签的多对多关联模型""" + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增ID""" + tag = fields.ForeignKeyField( + "models.GroupTag", related_name="groups", on_delete=fields.CASCADE + ) + """关联的标签""" + group_id = fields.CharField(max_length=255, description="群组ID") + """群组ID""" + + class Meta: # type: ignore + table = "group_tag_links" + table_description = "群组标签关联表" + unique_together = ("tag", "group_id") diff --git a/zhenxun/models/scheduled_job.py b/zhenxun/models/scheduled_job.py index 80d61674..564d4944 100644 --- a/zhenxun/models/scheduled_job.py +++ b/zhenxun/models/scheduled_job.py @@ -5,34 +5,52 @@ from zhenxun.services.db_context import Model class ScheduledJob(Model): id = fields.IntField(pk=True, generated=True, auto_increment=True) - """自增id""" + name = fields.CharField( + max_length=255, null=True, description="任务别名,方便用户辨识" + ) + created_by = fields.CharField( + max_length=255, null=True, description="创建任务的用户ID" + ) + required_permission = fields.IntField( + default=5, description="管理此任务所需的最低权限等级" + ) + source = fields.CharField( + max_length=50, default="USER", description="任务来源 (USER, PLUGIN_DEFAULT)" + ) + bot_id = fields.CharField( - 255, null=True, default=None, description="任务关联的Bot ID" + 255, null=True, description="执行任务的Bot约束 (具体Bot ID或平台)" ) - """任务关联的Bot ID""" plugin_name = fields.CharField(255, description="插件模块名") - """插件模块名""" - group_id = fields.CharField( - 255, - null=True, - description="群组ID, '__ALL_GROUPS__' 表示所有群, 为空表示全局任务", + target_type = fields.CharField( + max_length=50, description="目标类型 (GROUP, USER, TAG, ALL_GROUPS, GLOBAL)" ) - """群组ID, 为空表示全局任务""" + target_identifier = fields.CharField( + max_length=255, description="目标标识符 (群号, 标签名等)" + ) + trigger_type = fields.CharField( max_length=20, default="cron", description="触发器类型 (cron, interval, date)" ) - """触发器类型 (cron, interval, date)""" trigger_config = fields.JSONField(description="触发器具体配置") - """触发器具体配置""" job_kwargs = fields.JSONField( default=dict, description="传递给任务函数的额外关键字参数" ) - """传递给任务函数的额外关键字参数""" - is_enabled = fields.BooleanField(default=True, description="是否启用") - """是否启用""" - create_time = fields.DatetimeField(auto_now_add=True) - """创建时间""" - class Meta: # pyright: ignore [reportIncompatibleVariableOverride] - table = "scheduled_jobs" - table_description = "通用定时任务表" + is_enabled = fields.BooleanField(default=True, description="是否启用") + is_one_off = fields.BooleanField(default=False, description="是否为一次性任务") + last_run_at = fields.DatetimeField(null=True, description="上次执行完成时间") + last_run_status = fields.CharField( + max_length=20, null=True, description="上次执行状态 (SUCCESS, FAILURE)" + ) + consecutive_failures = fields.IntField(default=0, description="连续失败次数") + execution_options = fields.JSONField( + null=True, + description="任务执行的额外选项 (例如: jitter, spread, " + "interval, concurrency_policy)", + ) + create_time = fields.DatetimeField(auto_now_add=True) + + class Meta: # type: ignore + table = "scheduled_tasks" + table_description = "通用定时任务定义表" diff --git a/zhenxun/services/__init__.py b/zhenxun/services/__init__.py index 5bc353a6..74b0c149 100644 --- a/zhenxun/services/__init__.py +++ b/zhenxun/services/__init__.py @@ -45,12 +45,18 @@ from .llm import ( from .log import logger from .plugin_init import PluginInit, PluginInitManager from .renderer import renderer_service -from .scheduler import scheduler_manager +from .scheduler import ( + ExecutionPolicy, + ScheduleContext, + Trigger, + scheduler_manager, +) __all__ = [ "AI", "AIConfig", "CommonOverrides", + "ExecutionPolicy", "LLMContentPart", "LLMException", "LLMGenerationConfig", @@ -58,6 +64,8 @@ __all__ = [ "Model", "PluginInit", "PluginInitManager", + "ScheduleContext", + "Trigger", "avatar_service", "chat", "clear_model_cache", diff --git a/zhenxun/services/scheduler/__init__.py b/zhenxun/services/scheduler/__init__.py index c4a8ebba..7c0399ab 100644 --- a/zhenxun/services/scheduler/__init__.py +++ b/zhenxun/services/scheduler/__init__.py @@ -4,11 +4,10 @@ 提供一个统一的、持久化的定时任务管理器,供所有插件使用。 """ -from .job import ScheduleContext -from .lifecycle import _load_schedules_from_db -from .service import ExecutionPolicy, scheduler_manager -from .triggers import Trigger +from . import lifecycle +from .manager import scheduler_manager +from .types import ExecutionPolicy, ScheduleContext, Trigger -_ = _load_schedules_from_db +_ = lifecycle __all__ = ["ExecutionPolicy", "ScheduleContext", "Trigger", "scheduler_manager"] diff --git a/zhenxun/services/scheduler/adapter.py b/zhenxun/services/scheduler/adapter.py deleted file mode 100644 index 7cbb166a..00000000 --- a/zhenxun/services/scheduler/adapter.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -引擎适配层 (Adapter) - -封装所有对具体调度器引擎 (APScheduler) 的操作, -使上层服务与调度器实现解耦。 -""" - -from collections.abc import Callable - -from nonebot_plugin_apscheduler import scheduler - -from zhenxun.models.scheduled_job import ScheduledJob -from zhenxun.services.log import logger - -from .job import ScheduleContext, _execute_job - -JOB_PREFIX = "zhenxun_schedule_" - - -class APSchedulerAdapter: - """封装对 APScheduler 的操作""" - - @staticmethod - def _get_job_id(schedule_id: int) -> str: - """ - 生成 APScheduler 的 Job ID - - 参数: - schedule_id: 定时任务的ID。 - - 返回: - str: APScheduler 使用的 Job ID。 - """ - return f"{JOB_PREFIX}{schedule_id}" - - @staticmethod - def add_or_reschedule_job(schedule: ScheduledJob): - """ - 根据 ScheduledJob 添加或重新调度一个 APScheduler 任务 - - 参数: - schedule: 定时任务对象,包含任务的所有配置信息。 - """ - job_id = APSchedulerAdapter._get_job_id(schedule.id) - - if not isinstance(schedule.trigger_config, dict): - logger.error( - f"任务 {schedule.id} 的 trigger_config 不是字典类型: " - f"{type(schedule.trigger_config)}" - ) - return - - job = scheduler.get_job(job_id) - if job: - scheduler.reschedule_job( - job_id, trigger=schedule.trigger_type, **schedule.trigger_config - ) - logger.debug(f"已更新APScheduler任务: {job_id}") - else: - scheduler.add_job( - _execute_job, - trigger=schedule.trigger_type, - id=job_id, - misfire_grace_time=300, - args=[schedule.id], - **schedule.trigger_config, - ) - logger.debug(f"已添加新的APScheduler任务: {job_id}") - - @staticmethod - def remove_job(schedule_id: int): - """ - 移除一个 APScheduler 任务 - - 参数: - schedule_id: 要移除的定时任务ID。 - """ - job_id = APSchedulerAdapter._get_job_id(schedule_id) - try: - scheduler.remove_job(job_id) - logger.debug(f"已从APScheduler中移除任务: {job_id}") - except Exception: - pass - - @staticmethod - def pause_job(schedule_id: int): - """ - 暂停一个 APScheduler 任务 - - 参数: - schedule_id: 要暂停的定时任务ID。 - """ - job_id = APSchedulerAdapter._get_job_id(schedule_id) - try: - scheduler.pause_job(job_id) - except Exception: - pass - - @staticmethod - def resume_job(schedule_id: int): - """ - 恢复一个 APScheduler 任务 - - 参数: - schedule_id: 要恢复的定时任务ID。 - """ - job_id = APSchedulerAdapter._get_job_id(schedule_id) - try: - scheduler.resume_job(job_id) - except Exception: - import asyncio - - from .repository import ScheduleRepository - - async def _re_add_job(): - schedule = await ScheduleRepository.get_by_id(schedule_id) - if schedule: - APSchedulerAdapter.add_or_reschedule_job(schedule) - - asyncio.create_task(_re_add_job()) # noqa: RUF006 - - @staticmethod - def get_job_status(schedule_id: int) -> dict: - """ - 获取 APScheduler Job 的状态 - - 参数: - schedule_id: 定时任务的ID。 - - 返回: - dict: 包含任务状态信息的字典,包含next_run_time等字段。 - """ - job_id = APSchedulerAdapter._get_job_id(schedule_id) - job = scheduler.get_job(job_id) - return { - "next_run_time": job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") - if job and job.next_run_time - else "N/A", - "is_paused_in_scheduler": not bool(job.next_run_time) if job else "N/A", - } - - @staticmethod - def add_ephemeral_job( - job_id: str, - func: Callable, - trigger_type: str, - trigger_config: dict, - context: ScheduleContext, - ): - """ - 直接向 APScheduler 添加一个临时的、非持久化的任务 - - 参数: - job_id: 临时任务的唯一ID。 - func: 要执行的函数。 - trigger_type: 触发器类型。 - trigger_config: 触发器配置字典。 - context: 任务执行上下文。 - """ - job = scheduler.get_job(job_id) - if job: - logger.warning(f"尝试添加一个已存在的临时任务ID: {job_id},操作被忽略。") - return - - scheduler.add_job( - _execute_job, - trigger=trigger_type, - id=job_id, - misfire_grace_time=60, - args=[None], - kwargs={"context_override": context}, - **trigger_config, - ) - logger.debug(f"已添加新的临时APScheduler任务: {job_id}") diff --git a/zhenxun/services/scheduler/engine.py b/zhenxun/services/scheduler/engine.py new file mode 100644 index 00000000..7f711b9e --- /dev/null +++ b/zhenxun/services/scheduler/engine.py @@ -0,0 +1,482 @@ +""" +引擎适配层 (Adapter) 与 任务执行逻辑 (Job) + +封装所有对具体调度器引擎 (APScheduler) 的操作, +以及被 APScheduler 实际调度的函数。 +""" + +import asyncio +from collections.abc import Callable +from datetime import datetime +from functools import partial +import random + +import nonebot +from nonebot.adapters import Bot +from nonebot.dependencies import Dependent +from nonebot.exception import FinishedException, PausedException, SkippedException +from nonebot.matcher import Matcher +from nonebot.typing import T_State +from nonebot_plugin_apscheduler import scheduler +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.common_utils import CommonUtils +from zhenxun.utils.decorator.retry import Retry +from zhenxun.utils.pydantic_compat import parse_as + +from .repository import ScheduleRepository +from .types import ExecutionPolicy, ScheduleContext + +JOB_PREFIX = "zhenxun_schedule_" +SCHEDULE_CONCURRENCY_KEY = "all_groups_concurrency_limit" + + +class APSchedulerAdapter: + """封装对 APScheduler 的操作""" + + @staticmethod + def _get_job_id(schedule_id: int) -> str: + """ + 生成 APScheduler 的 Job ID + + 参数: + schedule_id: 定时任务的ID。 + + 返回: + str: APScheduler 使用的 Job ID。 + """ + return f"{JOB_PREFIX}{schedule_id}" + + @staticmethod + def add_or_reschedule_job(schedule: ScheduledJob): + """ + 根据 ScheduledJob 添加或重新调度一个 APScheduler 任务 + + 参数: + schedule: 定时任务对象,包含任务的所有配置信息。 + """ + job_id = APSchedulerAdapter._get_job_id(schedule.id) + + try: + scheduler.remove_job(job_id) + except Exception: + pass + + if not isinstance(schedule.trigger_config, dict): + logger.error( + f"任务 {schedule.id} 的 trigger_config 不是字典类型: " + f"{type(schedule.trigger_config)}" + ) + return + + trigger_params = schedule.trigger_config.copy() + execution_options = ( + schedule.execution_options + if isinstance(schedule.execution_options, dict) + else {} + ) + if jitter := execution_options.get("jitter"): + if isinstance(jitter, int) and jitter > 0: + trigger_params["jitter"] = jitter + + concurrency_policy = execution_options.get("concurrency_policy", "ALLOW") + job_params = { + "id": job_id, + "misfire_grace_time": 300, + "args": [schedule.id], + } + + if concurrency_policy == "SKIP": + job_params["max_instances"] = 1 + job_params["coalesce"] = True + elif concurrency_policy == "QUEUE": + job_params["max_instances"] = 1 + job_params["coalesce"] = False + + scheduler.add_job( + _execute_job, + trigger=schedule.trigger_type, + **job_params, + **trigger_params, + ) + logger.debug( + f"已添加或更新APScheduler任务: {job_id} | 并发策略: {concurrency_policy}, " + f"抖动: {trigger_params.get('jitter', '无')}" + ) + + @staticmethod + def remove_job(schedule_id: int): + """ + 移除一个 APScheduler 任务 + + 参数: + schedule_id: 要移除的定时任务ID。 + """ + job_id = APSchedulerAdapter._get_job_id(schedule_id) + try: + scheduler.remove_job(job_id) + logger.debug(f"已从APScheduler中移除任务: {job_id}") + except Exception: + pass + + @staticmethod + def pause_job(schedule_id: int): + """ + 暂停一个 APScheduler 任务 + + 参数: + schedule_id: 要暂停的定时任务ID。 + """ + job_id = APSchedulerAdapter._get_job_id(schedule_id) + try: + scheduler.pause_job(job_id) + except Exception: + pass + + @staticmethod + def resume_job(schedule_id: int): + """ + 恢复一个 APScheduler 任务 + + 参数: + schedule_id: 要恢复的定时任务ID。 + """ + job_id = APSchedulerAdapter._get_job_id(schedule_id) + try: + scheduler.resume_job(job_id) + except Exception: + import asyncio + + async def _re_add_job(): + schedule = await ScheduleRepository.get_by_id(schedule_id) + if schedule: + APSchedulerAdapter.add_or_reschedule_job(schedule) + + asyncio.create_task(_re_add_job()) # noqa: RUF006 + + @staticmethod + def get_job_status(schedule_id: int) -> dict: + """ + 获取 APScheduler Job 的状态 + + 参数: + schedule_id: 定时任务的ID。 + + 返回: + dict: 包含任务状态信息的字典,包含next_run_time等字段。 + """ + job_id = APSchedulerAdapter._get_job_id(schedule_id) + job = scheduler.get_job(job_id) + return { + "next_run_time": job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") + if job and job.next_run_time + else "N/A", + "is_paused_in_scheduler": not bool(job.next_run_time) if job else "N/A", + } + + @staticmethod + def add_ephemeral_job( + job_id: str, + func: Callable, + trigger_type: str, + trigger_config: dict, + context: ScheduleContext, + ): + """ + 直接向 APScheduler 添加一个临时的、非持久化的任务 + + 参数: + job_id: 临时任务的唯一ID。 + func: 要执行的函数。 + trigger_type: 触发器类型。 + trigger_config: 触发器配置字典。 + context: 任务执行上下文。 + """ + job = scheduler.get_job(job_id) + if job: + logger.warning(f"尝试添加一个已存在的临时任务ID: {job_id},操作被忽略。") + return + + scheduler.add_job( + _execute_job, + trigger=trigger_type, + id=job_id, + misfire_grace_time=60, + args=[None], + kwargs={"context_override": context}, + **trigger_config, + ) + logger.debug(f"已添加新的临时APScheduler任务: {job_id}") + + +async def _execute_single_job_instance( + schedule: ScheduledJob, bot, group_id: str | None = None +): + """ + 负责执行一个具体目标的任务实例。 + """ + from .manager import scheduler_manager + + plugin_name = schedule.plugin_name + if group_id is None and schedule.target_type == "GROUP": + group_id = schedule.target_identifier + + task_meta = scheduler_manager._registered_tasks.get(plugin_name) + + if not task_meta: + logger.error(f"无法执行任务:插件 '{plugin_name}' 在执行期间变得不可用。") + return + + is_blocked = await CommonUtils.task_is_block(bot, plugin_name, group_id) + if is_blocked: + target_desc = f"群 {group_id}" if group_id else "全局" + logger.info( + f"插件 '{plugin_name}' 的定时任务在目标 [{target_desc}] " + f"因功能被禁用而跳过执行。" + ) + return + + context = ScheduleContext( + schedule_id=schedule.id, + plugin_name=plugin_name, + bot_id=bot.self_id, + group_id=group_id, + job_kwargs=schedule.job_kwargs if isinstance(schedule.job_kwargs, dict) else {}, + ) + state: T_State = {ScheduleContext: context} + + policy_data = context.job_kwargs.pop("execution_policy", {}) + policy = ExecutionPolicy(**policy_data) + + async def task_execution_coro(): + injected_params = {"context": context} + + params_model = task_meta.get("model") + if params_model and isinstance(context.job_kwargs, dict): + try: + if isinstance(params_model, type) and issubclass( + params_model, BaseModel + ): + params_instance = parse_as(params_model, context.job_kwargs) + injected_params["params"] = params_instance # type: ignore + except Exception as e: + logger.error( + f"任务 {schedule.id} (目标: {group_id}) 参数验证失败: {e}", e=e + ) + raise + + async def wrapper(bot: Bot): + return await task_meta["func"](bot=bot, **injected_params) # type: ignore + + dependent = Dependent.parse( + call=wrapper, + allow_types=Matcher.HANDLER_PARAM_TYPES, + ) + return await dependent(bot=bot, state=state) + + try: + if policy.retries > 0: + on_success_handler = None + if policy.on_success_callback: + on_success_handler = partial(policy.on_success_callback, context) + + on_failure_handler = None + if policy.on_failure_callback: + on_failure_handler = partial(policy.on_failure_callback, context) + + retry_exceptions = tuple(policy.retry_on_exceptions or []) + + retry_decorator = Retry.api( + stop_max_attempt=policy.retries + 1, + strategy="exponential" if policy.retry_backoff else "fixed", + wait_fixed_seconds=policy.retry_delay_seconds, + exception=retry_exceptions, + on_success=on_success_handler, + on_failure=on_failure_handler, + log_name=f"ScheduledJob-{schedule.id}-{group_id or 'global'}", + ) + + decorated_executor = retry_decorator(task_execution_coro) + await decorated_executor() + else: + logger.info( + f"插件 '{plugin_name}' 开始为目标 [{group_id or '全局'}] " + f"执行定时任务 (ID: {schedule.id})。" + ) + await task_execution_coro() + + except (PausedException, FinishedException, SkippedException) as e: + logger.warning( + f"定时任务 {schedule.id} (目标: {group_id}) 被中断: {type(e).__name__}" + ) + except Exception as e: + logger.error( + f"执行定时任务 {schedule.id} (目标: {group_id}) " + f"时发生未被策略处理的最终错误", + e=e, + ) + + +async def _execute_job( + schedule_id: int | None, + force: bool = False, + context_override: ScheduleContext | None = None, +): + """ + APScheduler 调度的入口函数,现在作为分发器。 + """ + from .manager import scheduler_manager + + schedule = None + + if context_override: + plugin_name = context_override.plugin_name + task_meta = scheduler_manager._registered_tasks.get(plugin_name) + if not task_meta or not task_meta["func"]: + logger.error(f"无法执行临时任务:函数 '{plugin_name}' 未注册。") + return + + try: + bot = nonebot.get_bot() + logger.info(f"开始执行临时任务: {plugin_name}") + injected_params = {"context": context_override} + state: T_State = {ScheduleContext: context_override} + + async def wrapper(bot: Bot): + return await task_meta["func"](bot=bot, **injected_params) # type: ignore + + dependent = Dependent.parse( + call=wrapper, + allow_types=Matcher.HANDLER_PARAM_TYPES, + ) + await dependent(bot=bot, state=state) + logger.info(f"临时任务 '{plugin_name}' 执行完成。") + except Exception as e: + logger.error(f"执行临时任务 '{plugin_name}' 时发生错误", e=e) + return + + if schedule_id is None: + logger.error("执行持久化任务时 schedule_id 不能为空。") + return + + scheduler_manager._running_tasks.add(schedule_id) + try: + schedule = await ScheduleRepository.get_by_id(schedule_id) + if not schedule or (not schedule.is_enabled and not force): + logger.warning(f"定时任务 {schedule_id} 不存在或已禁用,跳过执行。") + return + + try: + bot = ( + nonebot.get_bot(schedule.bot_id) + if schedule.bot_id + else nonebot.get_bot() + ) + except (KeyError, ValueError): + logger.warning( + f"任务 {schedule_id} 需要的 Bot {schedule.bot_id} " + f"不在线,本次执行跳过。" + ) + raise + + resolver = scheduler_manager._target_resolvers.get(schedule.target_type) + if not resolver: + logger.error( + f"任务 {schedule.id} 的目标类型 '{schedule.target_type}' " + f"没有注册解析器,执行跳过。" + ) + raise ValueError(f"未知的目标类型: {schedule.target_type}") + + try: + resolved_targets = await resolver(schedule.target_identifier, bot) + except Exception as e: + logger.error(f"为任务 {schedule.id} 解析目标失败", e=e) + raise + + logger.info( + f"任务 {schedule.id} ({schedule.name or schedule.plugin_name}) 开始执行, " + f"目标类型: {schedule.target_type}, " + f"解析出 {len(resolved_targets)} 个目标" + ) + + concurrency_limit = Config.get_config( + "SchedulerManager", SCHEDULE_CONCURRENCY_KEY, 5 + ) + semaphore = asyncio.Semaphore(concurrency_limit if concurrency_limit > 0 else 5) + + spread_config = ( + schedule.execution_options + if isinstance(schedule.execution_options, dict) + else {} + ) + interval_seconds = spread_config.get("interval") + + if interval_seconds is not None and interval_seconds > 0: + logger.debug( + f"任务 {schedule.id}: 使用串行模式执行 {len(resolved_targets)} " + f"个目标,固定间隔 {interval_seconds} 秒。" + ) + for i, target_id in enumerate(resolved_targets): + if i > 0: + logger.debug( + f"任务 {schedule.id} 目标 [{target_id or '全局'}]: " + f"等待 {interval_seconds} 秒后执行。" + ) + await asyncio.sleep(interval_seconds) + await _execute_single_job_instance(schedule, bot, group_id=target_id) + else: + spread_seconds = spread_config.get("spread", 1.0) + + logger.debug( + f"任务 {schedule.id}: 将在 {spread_seconds:.2f} 秒内分散执行 " + f"{len(resolved_targets)} 个目标。" + ) + + async def worker(target_id: str | None): + delay = random.uniform(0.1, spread_seconds) + logger.debug( + f"任务 {schedule.id} 目标 [{target_id or '全局'}]: " + f"随机延迟 {delay:.2f} 秒后执行。" + ) + await asyncio.sleep(delay) + async with semaphore: + await _execute_single_job_instance( + schedule, bot, group_id=target_id + ) + + tasks_to_run = [worker(target_id) for target_id in resolved_targets] + if tasks_to_run: + await asyncio.gather(*tasks_to_run, return_exceptions=True) + + schedule.last_run_at = datetime.now() + schedule.last_run_status = "SUCCESS" + schedule.consecutive_failures = 0 + await schedule.save( + update_fields=["last_run_at", "last_run_status", "consecutive_failures"] + ) + + if schedule.is_one_off: + logger.info(f"一次性任务 {schedule.id} 执行成功,将被删除。") + await ScheduledJob.filter(id=schedule.id).delete() + APSchedulerAdapter.remove_job(schedule.id) + if schedule.plugin_name.startswith("runtime_one_off__"): + scheduler_manager._registered_tasks.pop(schedule.plugin_name, None) + logger.debug(f"已注销一次性运行时任务: {schedule.plugin_name}") + + except Exception as e: + logger.error(f"执行任务 {schedule_id} 期间发生严重错误", e=e) + + if schedule: + schedule.last_run_at = datetime.now() + schedule.last_run_status = "FAILURE" + schedule.consecutive_failures = (schedule.consecutive_failures or 0) + 1 + await schedule.save( + update_fields=["last_run_at", "last_run_status", "consecutive_failures"] + ) + + finally: + if schedule_id is not None: + scheduler_manager._running_tasks.discard(schedule_id) diff --git a/zhenxun/services/scheduler/job.py b/zhenxun/services/scheduler/job.py deleted file mode 100644 index e7024dfc..00000000 --- a/zhenxun/services/scheduler/job.py +++ /dev/null @@ -1,239 +0,0 @@ -""" -定时任务的执行逻辑 - -包含被 APScheduler 实际调度的函数,以及处理不同目标(单个、所有群组)的执行策略。 -""" - -import asyncio -import copy -from functools import partial -import random - -import nonebot -from nonebot.adapters import Bot -from nonebot.dependencies import Dependent -from nonebot.exception import FinishedException, PausedException, SkippedException -from nonebot.matcher import Matcher -from nonebot.typing import T_State -from pydantic import BaseModel, Field - -from zhenxun.configs.config import Config -from zhenxun.models.scheduled_job import ScheduledJob -from zhenxun.services.log import logger -from zhenxun.utils.common_utils import CommonUtils -from zhenxun.utils.decorator.retry import Retry -from zhenxun.utils.platform import PlatformUtils -from zhenxun.utils.pydantic_compat import parse_as - -SCHEDULE_CONCURRENCY_KEY = "all_groups_concurrency_limit" - - -class ScheduleContext(BaseModel): - """ - 定时任务执行上下文,可通过依赖注入获取。 - """ - - schedule_id: int = Field(..., description="数据库中的任务ID") - plugin_name: str = Field(..., description="任务所属的插件名称") - bot_id: str | None = Field(None, description="执行任务的Bot ID") - group_id: str | None = Field(None, description="任务目标群组ID") - job_kwargs: dict = Field(default_factory=dict, description="任务配置的参数") - - -async def _execute_single_job_instance(schedule: ScheduledJob, bot): - """ - 负责执行一个具体目标的任务实例。 - """ - plugin_name = schedule.plugin_name - group_id = schedule.group_id - - from .service import ExecutionPolicy, scheduler_manager - - task_meta = scheduler_manager._registered_tasks.get(plugin_name) - - if not task_meta: - logger.error(f"无法执行任务:插件 '{plugin_name}' 在执行期间变得不可用。") - return - - is_blocked = await CommonUtils.task_is_block(bot, plugin_name, group_id) - if is_blocked: - target_desc = f"群 {group_id}" if group_id else "全局" - logger.info( - f"插件 '{plugin_name}' 的定时任务在目标 [{target_desc}] " - f"因功能被禁用而跳过执行。" - ) - return - - context = ScheduleContext( - schedule_id=schedule.id, - plugin_name=schedule.plugin_name, - bot_id=bot.self_id, - group_id=schedule.group_id, - job_kwargs=schedule.job_kwargs if isinstance(schedule.job_kwargs, dict) else {}, - ) - state: T_State = {ScheduleContext: context} - - policy_data = context.job_kwargs.pop("execution_policy", {}) - policy = ExecutionPolicy(**policy_data) - - async def task_execution_coro(): - injected_params = {"context": context} - - params_model = task_meta.get("model") - if params_model and isinstance(context.job_kwargs, dict): - try: - if isinstance(params_model, type) and issubclass( - params_model, BaseModel - ): - params_instance = parse_as(params_model, context.job_kwargs) - injected_params["params"] = params_instance # type: ignore - except Exception as e: - logger.error( - f"任务 {schedule.id} (目标: {group_id}) 参数验证失败: {e}", e=e - ) - raise - - async def wrapper(bot: Bot): - return await task_meta["func"](bot=bot, **injected_params) # type: ignore - - dependent = Dependent.parse( - call=wrapper, - allow_types=Matcher.HANDLER_PARAM_TYPES, - ) - return await dependent(bot=bot, state=state) - - try: - if policy.retries > 0: - on_success_handler = None - if policy.on_success_callback: - on_success_handler = partial(policy.on_success_callback, context) - - on_failure_handler = None - if policy.on_failure_callback: - on_failure_handler = partial(policy.on_failure_callback, context) - - retry_exceptions = tuple(policy.retry_on_exceptions or []) - - retry_decorator = Retry.api( - stop_max_attempt=policy.retries + 1, - strategy="exponential" if policy.retry_backoff else "fixed", - wait_fixed_seconds=policy.retry_delay_seconds, - exception=retry_exceptions, - on_success=on_success_handler, - on_failure=on_failure_handler, - log_name=f"ScheduledJob-{schedule.id}-{schedule.group_id or 'global'}", - ) - - decorated_executor = retry_decorator(task_execution_coro) - await decorated_executor() - else: - logger.info( - f"插件 '{plugin_name}' 开始为目标 [{group_id or '全局'}] " - f"执行定时任务 (ID: {schedule.id})。" - ) - await task_execution_coro() - - except (PausedException, FinishedException, SkippedException) as e: - logger.warning( - f"定时任务 {schedule.id} (目标: {group_id}) 被中断: {type(e).__name__}" - ) - except Exception as e: - logger.error( - f"执行定时任务 {schedule.id} (目标: {group_id}) " - f"时发生未被策略处理的最终错误", - e=e, - ) - - -async def _execute_job(schedule_id: int): - """ - APScheduler 调度的入口函数,现在作为分发器。 - """ - from .repository import ScheduleRepository - from .service import scheduler_manager - - scheduler_manager._running_tasks.add(schedule_id) - try: - schedule = await ScheduleRepository.get_by_id(schedule_id) - if not schedule or not schedule.is_enabled: - logger.warning(f"定时任务 {schedule_id} 不存在或已禁用,跳过执行。") - return - - if schedule.plugin_name not in scheduler_manager._registered_tasks: - logger.error( - f"无法执行定时任务:插件 '{schedule.plugin_name}' " - f"未注册或已卸载。将禁用该任务。" - ) - schedule.is_enabled = False - await ScheduleRepository.save(schedule, update_fields=["is_enabled"]) - from .adapter import APSchedulerAdapter - - APSchedulerAdapter.remove_job(schedule.id) - return - - try: - bot = ( - nonebot.get_bot(schedule.bot_id) - if schedule.bot_id - else nonebot.get_bot() - ) - except (KeyError, ValueError): - logger.warning( - f"定时任务 {schedule_id} 需要的 Bot {schedule.bot_id} " - f"不在线,本次执行跳过。" - ) - return - - if schedule.group_id == scheduler_manager.ALL_GROUPS: - concurrency_limit = Config.get_config( - "SchedulerManager", SCHEDULE_CONCURRENCY_KEY, 5 - ) - if not isinstance(concurrency_limit, int) or concurrency_limit <= 0: - concurrency_limit = 5 - - logger.info( - f"开始执行针对 [所有群组] 的任务 (ID: {schedule.id}, " - f"插件: {schedule.plugin_name}, Bot: {bot.self_id})," - f"并发限制: {concurrency_limit}" - ) - - try: - group_list, _ = await PlatformUtils.get_group_list(bot) - all_gids = { - g.group_id for g in group_list if g.group_id and not g.channel_id - } - except Exception as e: - logger.error(f"为 'all' 任务获取 Bot {bot.self_id} 的群列表失败", e=e) - return - - specific_tasks_gids = set( - await ScheduledJob.filter( - plugin_name=schedule.plugin_name, group_id__in=list(all_gids) - ).values_list("group_id", flat=True) - ) - - semaphore = asyncio.Semaphore(concurrency_limit) - - async def worker(gid: str): - await asyncio.sleep(random.uniform(0.1, 1.0)) - async with semaphore: - temp_schedule = copy.deepcopy(schedule) - temp_schedule.group_id = gid - await _execute_single_job_instance(temp_schedule, bot) - - tasks_to_run = [ - worker(gid) for gid in all_gids if gid not in specific_tasks_gids - ] - - if tasks_to_run: - await asyncio.gather(*tasks_to_run) - logger.info( - f"针对 [所有群组] 的任务 (ID: {schedule.id}) 执行完毕," - f"共处理 {len(tasks_to_run)} 个群组。" - ) - - else: - await _execute_single_job_instance(schedule, bot) - - finally: - scheduler_manager._running_tasks.discard(schedule_id) diff --git a/zhenxun/services/scheduler/lifecycle.py b/zhenxun/services/scheduler/lifecycle.py index 2822ccf8..1d1ad930 100644 --- a/zhenxun/services/scheduler/lifecycle.py +++ b/zhenxun/services/scheduler/lifecycle.py @@ -8,10 +8,10 @@ from zhenxun.services.log import logger from zhenxun.utils.manager.priority_manager import PriorityLifecycle from zhenxun.utils.pydantic_compat import model_dump -from .adapter import APSchedulerAdapter -from .job import ScheduleContext +from .engine import APSchedulerAdapter +from .manager import scheduler_manager from .repository import ScheduleRepository -from .service import scheduler_manager +from .types import ScheduleContext @PriorityLifecycle.on_startup(priority=90) @@ -37,7 +37,7 @@ async def _load_schedules_from_db(): query_kwargs = { "plugin_name": plugin_name, - "group_id": group_id, + "target_identifier": group_id or "", "bot_id": bot_id, } exists = await ScheduleRepository.exists(**query_kwargs) @@ -49,9 +49,13 @@ async def _load_schedules_from_db(): task_info.trigger, exclude={"trigger_type"} ) + target_type = "GROUP" if group_id else "GLOBAL" + target_identifier = group_id or "" + schedule = await scheduler_manager.add_schedule( plugin_name=plugin_name, - group_id=group_id, + target_type=target_type, + target_identifier=target_identifier, trigger_type=task_info.trigger.trigger_type, trigger_config=trigger_config_dict, job_kwargs=task_info.job_kwargs, diff --git a/zhenxun/services/scheduler/service.py b/zhenxun/services/scheduler/manager.py similarity index 64% rename from zhenxun/services/scheduler/service.py rename to zhenxun/services/scheduler/manager.py index 4f99f1de..103e4845 100644 --- a/zhenxun/services/scheduler/service.py +++ b/zhenxun/services/scheduler/manager.py @@ -1,81 +1,92 @@ """ -服务层 (Service) +服务层 (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 +from zhenxun.utils.pydantic_compat import model_dump, model_validate -from .adapter import APSchedulerAdapter -from .job import ScheduleContext, _execute_job +from .engine import APSchedulerAdapter from .repository import ScheduleRepository -from .targeter import ScheduleTargeter -from .triggers import BaseTrigger - - -class ExecutionPolicy(BaseModel): - """ - 封装定时任务的执行策略,包括重试和回调。 - """ - - retries: int = 0 - retry_delay_seconds: int = 30 - retry_backoff: bool = False - retry_on_exceptions: list[type[Exception]] | None = None - on_success_callback: Callable[[ScheduleContext, Any], Awaitable[None]] | None = None - on_failure_callback: ( - Callable[[ScheduleContext, Exception], Awaitable[None]] | None - ) = None - - class Config: - arbitrary_types_allowed = True - - -class ScheduledJobDeclaration(BaseModel): - """用于在启动时声明默认定时任务的内部数据模型""" - - plugin_name: str - group_id: str | None - bot_id: str | None - trigger: BaseTrigger - job_kwargs: dict[str, Any] - - class Config: - arbitrary_types_allowed = True - - -class EphemeralJobDeclaration(BaseModel): - """用于在启动时声明临时任务的内部数据模型""" - - plugin_name: str - func: Callable[..., Coroutine] - trigger: BaseTrigger - - class Config: - arbitrary_types_allowed = True +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] | None]] + 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: """ @@ -96,22 +107,12 @@ class SchedulerManager: 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, ): """ 声明式定时任务的统一装饰器。 - - 此装饰器用于将一个异步函数注册为一个可调度的定时任务, - 并为其创建一个默认的调度计划。 - - 参数: - trigger: 一个由 `Trigger` 工厂类创建的触发器配置对象 - (例如 `Trigger.cron(hour=8)`)。 - group_id: 默认的目标群组ID。`None` 表示全局任务, - `SchedulerManager.ALL_GROUPS` 表示所有群组。 - bot_id: 默认的目标Bot ID,`None` 表示使用任意可用Bot。 - default_params: (可选) 一个Pydantic模型实例,为任务提供默认参数。 - 任务函数需要有对应的Pydantic模型类型注解。 - policy: (可选) 一个ExecutionPolicy实例,定义任务的执行策略。 """ def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]: @@ -122,7 +123,7 @@ class SchedulerManager: plugin_name = plugin.name params_model = None - from .job import ScheduleContext + from .types import ScheduleContext for param in inspect.signature(func).parameters.values(): if ( @@ -138,6 +139,9 @@ class SchedulerManager: 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 {} @@ -165,13 +169,6 @@ class SchedulerManager: def runtime_job(self, trigger: BaseTrigger): """ 声明一个临时的、非持久化的定时任务。 - - 这个任务只存在于内存中,随程序重启而消失。 - 它非常适合用于插件内部的、固定的、无需用户配置的系统级定时任务。 - 被此装饰器修饰的函数依然可以享受完整的依赖注入功能。 - - 参数: - trigger: 一个由 `Trigger` 工厂类创建的触发器配置对象。 """ def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]: @@ -203,17 +200,17 @@ class SchedulerManager: return decorator def register( - self, plugin_name: str, params_model: type[BaseModel] | None = None + 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: """ 注册可调度的任务函数 - - 参数: - plugin_name: 插件名称,用于标识任务。 - params_model: 参数验证模型,继承自BaseModel的类。 - - 返回: - Callable: 装饰器函数。 """ def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]: @@ -222,6 +219,11 @@ class SchedulerManager: 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( @@ -234,25 +236,14 @@ class SchedulerManager: def get_registered_plugins(self) -> list[str]: """ 获取已注册插件列表 - - 返回: - list[str]: 已注册的插件名称列表。 """ return list(self._registered_tasks.keys()) async def run_at(self, func: Callable[..., Coroutine], trigger: BaseTrigger) -> str: """ - 【新增】在未来的某个时间点,运行一个一次性的临时任务。 - - 这是一个编程式API,用于动态调度一个非持久化的任务。 - - 参数: - func: 要执行的异步函数。 - trigger: 一个由 `Trigger` 工廠類創建的觸發器配置對象。 - - 返回: - str: 临时任务的唯一ID,可用于未来的管理(如取消)。 + 在未来的某个时间点,运行一个一次性的临时任务。 """ + job_id = f"ephemeral_runtime_{uuid.uuid4()}" context = ScheduleContext( @@ -273,6 +264,47 @@ class SchedulerManager: 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, @@ -285,18 +317,6 @@ class SchedulerManager: ) -> "ScheduledJob | None": """ 添加每日定时任务 - - 参数: - plugin_name: 插件名称。 - group_id: 目标群组ID,None表示全局任务。 - hour: 执行小时(0-23)。 - minute: 执行分钟(0-59)。 - second: 执行秒数(0-59),默认为0。 - job_kwargs: 任务参数字典。 - bot_id: 目标Bot ID,None表示使用默认Bot。 - - 返回: - ScheduledJob | None: 创建的任务信息,失败时返回None。 """ trigger_config = { "hour": hour, @@ -306,9 +326,10 @@ class SchedulerManager: } return await self.add_schedule( plugin_name, - group_id, - "cron", - trigger_config, + 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, ) @@ -329,17 +350,6 @@ class SchedulerManager: ) -> "ScheduledJob | None": """ 添加间隔性定时任务 - - 参数: - plugin_name: 插件名称。 - group_id: 目标群组ID,None表示全局任务。 - weeks/days/hours/minutes/seconds: 间隔时间,至少指定一个。 - start_date: 开始时间,None表示立即开始。 - job_kwargs: 任务参数字典。 - bot_id: 目标Bot ID。 - - 返回: - ScheduledJob | None: 创建的任务信息,失败时返回None。 """ trigger_config = { "weeks": weeks, @@ -352,9 +362,10 @@ class SchedulerManager: trigger_config = {k: v for k, v in trigger_config.items() if v} return await self.add_schedule( plugin_name, - group_id, - "interval", - trigger_config, + 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, ) @@ -384,11 +395,7 @@ class SchedulerManager: return False, f"插件 '{plugin_name}' 的参数模型配置错误" try: - model_validate = getattr(params_model, "model_validate", None) - if not model_validate: - return False, f"插件 '{plugin_name}' 的参数模型不支持验证" - - validated_model = model_validate(job_kwargs) + validated_model = model_validate(params_model, job_kwargs) return True, model_dump(validated_model) except ValidationError as e: @@ -400,22 +407,37 @@ class SchedulerManager: async def add_schedule( self, plugin_name: str, - group_id: str | None, + 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: 插件名称。 - group_id: 目标群组ID,None表示全局任务。 - trigger_type: 触发器类型,如'cron'、'interval'等。 + target_type: 目标类型 (GROUP, USER, TAG, ALL_GROUPS, GLOBAL)。 + target_identifier: 目标标识符。 + trigger_type: 触发器类型 (cron, interval, date)。 trigger_config: 触发器配置字典。 - job_kwargs: 任务参数字典。 - bot_id: 目标Bot ID,None表示使用默认Bot。 + 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。 @@ -429,51 +451,84 @@ class SchedulerManager: logger.error(f"任务参数校验失败: {result}") return None - search_kwargs = {"plugin_name": plugin_name, "group_id": group_id} - if bot_id and group_id == self.ALL_GROUPS: + 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 - else: - search_kwargs["bot_id__isnull"] = True 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 = "设置" if created else "更新" + action_str = "创建" if created else "更新" logger.info( - f"已成功{action}插件 '{plugin_name}' 的定时任务 (ID: {schedule.id})。" + f"已成功{action_str}任务 '{name or plugin_name}' (ID: {schedule.id})" ) return schedule async def get_schedules( - self, - plugin_name: str | None = None, - group_id: str | None = None, - bot_id: str | None = None, - ) -> list[ScheduledJob]: + self, page: int | None = None, page_size: int | None = None, **filters: Any + ) -> tuple[list[ScheduledJob], int]: """ 根据条件获取定时任务列表 - - 参数: - plugin_name: 插件名称,None表示不限制。 - group_id: 群组ID,None表示不限制。 - bot_id: Bot ID,None表示不限制。 - - 返回: - list[ScheduledJob]: 符合条件的任务信息列表。 """ + cleaned_filters = {k: v for k, v in filters.items() if v is not None} return await ScheduleRepository.query_schedules( - plugin_name=plugin_name, group_id=group_id, bot_id=bot_id + 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, @@ -483,15 +538,6 @@ class SchedulerManager: ) -> tuple[bool, str]: """ 更新定时任务配置 - - 参数: - schedule_id: 任务ID。 - trigger_type: 新的触发器类型,None表示不更新。 - trigger_config: 新的触发器配置,None表示不更新。 - job_kwargs: 新的任务参数,None表示不更新。 - - 返回: - tuple[bool, str]: (是否成功, 结果消息)。 """ schedule = await ScheduleRepository.get_by_id(schedule_id) if not schedule: @@ -533,12 +579,6 @@ class SchedulerManager: async def get_schedule_status(self, schedule_id: int) -> dict | None: """ 获取定时任务的详细状态信息 - - 参数: - schedule_id: 定时任务的ID。 - - 返回: - dict | None: 任务详细信息字典,不存在时返回None。 """ schedule = await ScheduleRepository.get_by_id(schedule_id) if not schedule: @@ -556,7 +596,8 @@ class SchedulerManager: "id": schedule.id, "bot_id": schedule.bot_id, "plugin_name": schedule.plugin_name, - "group_id": schedule.group_id, + "target_type": schedule.target_type, + "target_identifier": schedule.target_identifier, "is_enabled": status_text, "trigger_type": schedule.trigger_type, "trigger_config": schedule.trigger_config, @@ -567,12 +608,6 @@ class SchedulerManager: async def pause_schedule(self, schedule_id: int) -> tuple[bool, str]: """ 暂停指定的定时任务 - - 参数: - schedule_id: 要暂停的定时任务ID。 - - 返回: - tuple[bool, str]: (是否成功, 操作结果消息)。 """ schedule = await ScheduleRepository.get_by_id(schedule_id) if not schedule or not schedule.is_enabled: @@ -586,12 +621,6 @@ class SchedulerManager: async def resume_schedule(self, schedule_id: int) -> tuple[bool, str]: """ 恢复指定的定时任务 - - 参数: - schedule_id: 要恢复的定时任务ID。 - - 返回: - tuple[bool, str]: (是否成功, 操作结果消息)。 """ schedule = await ScheduleRepository.get_by_id(schedule_id) if not schedule or schedule.is_enabled: @@ -605,13 +634,9 @@ class SchedulerManager: async def trigger_now(self, schedule_id: int) -> tuple[bool, str]: """ 立即手动触发指定的定时任务 - - 参数: - schedule_id: 要触发的定时任务ID。 - - 返回: - 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} 的定时任务。" @@ -619,12 +644,23 @@ class SchedulerManager: return False, f"插件 '{schedule.plugin_name}' 没有注册可用的定时任务。" try: - await _execute_job(schedule.id) + 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() -scheduler = scheduler_manager diff --git a/zhenxun/services/scheduler/repository.py b/zhenxun/services/scheduler/repository.py index b9a00c5a..7214503a 100644 --- a/zhenxun/services/scheduler/repository.py +++ b/zhenxun/services/scheduler/repository.py @@ -64,9 +64,9 @@ class ScheduleRepository: async def get_by_plugin_and_group( plugin_name: str, group_ids: list[str] ) -> list[ScheduledJob]: - """根据插件和群组ID列表获取任务""" + """[DEPRECATED] 根据插件和群组ID列表获取任务""" return await ScheduledJob.filter( - plugin_name=plugin_name, group_id__in=group_ids + plugin_name=plugin_name, target_descriptor__in=group_ids ).all() @staticmethod @@ -77,20 +77,30 @@ class ScheduleRepository: return await ScheduledJob.update_or_create(defaults=defaults, **kwargs) @staticmethod - async def query_schedules(**filters: Any) -> list[ScheduledJob]: + async def query_schedules( + page: int | None = None, page_size: int | None = None, **filters: Any + ) -> tuple[list[ScheduledJob], int]: """ 根据任意条件查询任务列表 参数: + page: 页码(从1开始) + page_size: 每页数量 **filters: 过滤条件,如 group_id="123", plugin_name="abc" 返回: - list[ScheduledJob]: 任务列表 + tuple[list[ScheduledJob], int]: (任务列表, 总数) """ cleaned_filters = {k: v for k, v in filters.items() if v is not None} - if not cleaned_filters: - return await ScheduledJob.all() - return await ScheduledJob.filter(**cleaned_filters).all() + query = ScheduledJob.filter(**cleaned_filters) + + total_count = await query.count() + + if page is not None and page_size is not None: + offset = (page - 1) * page_size + query = query.offset(offset).limit(page_size) + + return await query.all(), total_count @staticmethod def filter(**kwargs: Any) -> QuerySet[ScheduledJob]: diff --git a/zhenxun/services/scheduler/targeter.py b/zhenxun/services/scheduler/targeting.py similarity index 69% rename from zhenxun/services/scheduler/targeter.py rename to zhenxun/services/scheduler/targeting.py index c35de8b4..2d8407fa 100644 --- a/zhenxun/services/scheduler/targeter.py +++ b/zhenxun/services/scheduler/targeting.py @@ -1,14 +1,46 @@ """ -目标选择器 (Targeter) +目标解析与选择器 (Targeting) -提供链式API,用于构建和执行对多个定时任务的批量操作。 +提供用于解析任务目标和批量操作目标的 ScheduleTargeter 类。 """ from collections.abc import Callable, Coroutine from typing import Any -from .adapter import APSchedulerAdapter -from .repository import ScheduleRepository +from nonebot.adapters import Bot + +from zhenxun.services.tags import tag_manager + +__all__ = [ + "ScheduleTargeter", + "_resolve_all_groups", + "_resolve_global_or_user", + "_resolve_group", + "_resolve_tag", + "_resolve_user", +] + + +async def _resolve_group(target_identifier: str, bot: Bot) -> list[str | None]: + return [target_identifier] + + +async def _resolve_tag(target_identifier: str, bot: Bot) -> list[str | None]: + result = await tag_manager.resolve_tag_to_group_ids(target_identifier) + return result # type: ignore + + +async def _resolve_user(target_identifier: str, bot: Bot) -> list[str | None]: + return [target_identifier] + + +async def _resolve_all_groups(target_identifier: str, bot: Bot) -> list[str | None]: + result = await tag_manager.resolve_tag_to_group_ids("@all", bot=bot) + return result + + +async def _resolve_global_or_user(target_identifier: str, bot: Bot) -> list[str | None]: + return [None] class ScheduleTargeter: @@ -34,6 +66,8 @@ class ScheduleTargeter: 返回: list[ScheduledJob]: 符合过滤条件的任务列表。 """ + from .repository import ScheduleRepository + query = ScheduleRepository.filter(**self._filters) return await query.all() @@ -48,12 +82,14 @@ class ScheduleTargeter: return f"任务 ID {self._filters['id']} 的" parts = [] - if "group_id" in self._filters: - group_id = self._filters["group_id"] - if group_id == self._manager.ALL_GROUPS: + if "target_descriptor" in self._filters: + descriptor = self._filters["target_descriptor"] + if descriptor == self._manager.ALL_GROUPS: parts.append("所有群组中") + elif descriptor.startswith("tag:"): + parts.append(f"标签 '{descriptor[4:]}' 的") else: - parts.append(f"群 {group_id} 中") + parts.append(f"群 {descriptor} 中") if "plugin_name" in self._filters: parts.append(f"插件 '{self._filters['plugin_name']}' 的") @@ -111,6 +147,9 @@ class ScheduleTargeter: 返回: tuple[int, str]: (成功移除的任务数量, 操作结果消息)。 """ + from .engine import APSchedulerAdapter + from .repository import ScheduleRepository + schedules = await self._get_schedules() if not schedules: target_desc = self._generate_target_description() diff --git a/zhenxun/services/scheduler/types.py b/zhenxun/services/scheduler/types.py new file mode 100644 index 00000000..c05bcf79 --- /dev/null +++ b/zhenxun/services/scheduler/types.py @@ -0,0 +1,151 @@ +""" +定时任务服务的数据模型与类型定义 +""" + +from collections.abc import Awaitable, Callable +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class BaseTrigger(BaseModel): + """触发器配置的基类""" + + trigger_type: str = Field(..., exclude=True) + + +class CronTrigger(BaseTrigger): + """Cron 触发器配置""" + + trigger_type: Literal["cron"] = "cron" # type: ignore + year: int | str | None = None + month: int | str | None = None + day: int | str | None = None + week: int | str | None = None + day_of_week: int | str | None = None + hour: int | str | None = None + minute: int | str | None = None + second: int | str | None = None + start_date: datetime | str | None = None + end_date: datetime | str | None = None + timezone: str | None = None + jitter: int | None = None + + +class IntervalTrigger(BaseTrigger): + """Interval 触发器配置""" + + trigger_type: Literal["interval"] = "interval" # type: ignore + weeks: int = 0 + days: int = 0 + hours: int = 0 + minutes: int = 0 + seconds: int = 0 + start_date: datetime | str | None = None + end_date: datetime | str | None = None + timezone: str | None = None + jitter: int | None = None + + +class DateTrigger(BaseTrigger): + """Date 触发器配置""" + + trigger_type: Literal["date"] = "date" # type: ignore + run_date: datetime | str + timezone: str | None = None + + +class Trigger: + """ + 一个用于创建类型安全触发器配置的工厂类。 + 提供了流畅的、具备IDE自动补全功能的API。 + """ + + @staticmethod + def cron(**kwargs) -> CronTrigger: + """创建一个 Cron 触发器配置。""" + return CronTrigger(**kwargs) + + @staticmethod + def interval(**kwargs) -> IntervalTrigger: + """创建一个 Interval 触发器配置。""" + return IntervalTrigger(**kwargs) + + @staticmethod + def date(**kwargs) -> DateTrigger: + """创建一个 Date 触发器配置。""" + return DateTrigger(**kwargs) + + +class ExecutionOptions(BaseModel): + """ + 封装定时任务的执行策略,包括重试和回调。 + """ + + jitter: int | None = Field(None, description="触发时间抖动(秒)") + spread: int | None = Field( + None, description="(并发模式)多目标执行的最大分散延迟(秒)" + ) + interval: int | None = Field( + None, description="多目标执行的固定间隔(秒),设置后将强制串行执行" + ) + concurrency_policy: Literal["ALLOW", "SKIP", "QUEUE"] = Field( + "ALLOW", description="并发策略" + ) + retries: int = 0 + retry_delay_seconds: int = 30 + + +class ScheduleContext(BaseModel): + """ + 定时任务执行上下文,可通过依赖注入获取。 + """ + + schedule_id: int = Field(..., description="数据库中的任务ID") + plugin_name: str = Field(..., description="任务所属的插件名称") + bot_id: str | None = Field(None, description="执行任务的Bot ID") + group_id: str | None = Field(None, description="当前执行实例的目标群组ID") + job_kwargs: dict = Field(default_factory=dict, description="任务配置的参数") + + +class ExecutionPolicy(BaseModel): + """ + 封装定时任务的执行策略,包括重试和回调。 + """ + + retries: int = 0 + retry_delay_seconds: int = 30 + retry_backoff: bool = False + retry_on_exceptions: list[type[Exception]] | None = None + on_success_callback: Callable[[ScheduleContext, Any], Awaitable[None]] | None = None + on_failure_callback: ( + Callable[[ScheduleContext, Exception], Awaitable[None]] | None + ) = None + + class Config: + arbitrary_types_allowed = True + + +class ScheduledJobDeclaration(BaseModel): + """用于在启动时声明默认定时任务的内部数据模型""" + + plugin_name: str + group_id: str | None + bot_id: str | None + trigger: BaseTrigger + job_kwargs: dict[str, Any] + + class Config: + arbitrary_types_allowed = True + + +class EphemeralJobDeclaration(BaseModel): + """用于在启动时声明临时任务的内部数据模型""" + + plugin_name: str + func: Callable[..., Awaitable[Any]] + trigger: BaseTrigger + + class Config: + arbitrary_types_allowed = True diff --git a/zhenxun/services/tags/__init__.py b/zhenxun/services/tags/__init__.py new file mode 100644 index 00000000..19379672 --- /dev/null +++ b/zhenxun/services/tags/__init__.py @@ -0,0 +1,11 @@ +""" +标签服务入口,提供 ``TagManager`` 实例并加载内置规则。 +""" + +from .manager import TagManager + +tag_manager = TagManager() + +from . import filters # noqa: F401 + +__all__ = ["tag_manager"] diff --git a/zhenxun/services/tags/filters.py b/zhenxun/services/tags/filters.py new file mode 100644 index 00000000..5278dba4 --- /dev/null +++ b/zhenxun/services/tags/filters.py @@ -0,0 +1,11 @@ +""" +动态标签的内置过滤器集合,可通过装饰器注册到标签管理器。 +""" + +from . import tag_manager + +tag_manager.add_field_rule("member_count", db_field="member_count", value_type=int) +tag_manager.add_field_rule("level", db_field="level", value_type=int) +tag_manager.add_field_rule("status", db_field="status", value_type=bool) +tag_manager.add_field_rule("is_super", db_field="is_super", value_type=bool) +tag_manager.add_field_rule("group_name", db_field="group_name", value_type=str) diff --git a/zhenxun/services/tags/manager.py b/zhenxun/services/tags/manager.py new file mode 100644 index 00000000..8c914baa --- /dev/null +++ b/zhenxun/services/tags/manager.py @@ -0,0 +1,527 @@ +""" +标签服务的核心实现,负责标签的增删改查与动态规则解析。 +""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from functools import partial +from typing import Any, ClassVar + +from aiocache import Cache, cached +from arclet.alconna import Alconna, Args +from nonebot.adapters import Bot +from tortoise.exceptions import IntegrityError +from tortoise.expressions import Q +from tortoise.transactions import in_transaction + +from zhenxun.models.group_console import GroupConsole +from zhenxun.models.group_tag import GroupTag, GroupTagLink +from zhenxun.services.log import logger +from zhenxun.utils.platform import PlatformUtils + +from .models import ( + ErrorResult, + IDSetResult, + QueryResult, + RuleExecutionError, + RuleExecutionResult, +) + + +@dataclass +class HandlerInfo: + """存储已注册处理器的元信息。""" + + func: Callable[..., Coroutine[Any, Any, RuleExecutionResult]] + alconna: Alconna + + +def invalidate_on_change(func: Callable) -> Callable: + """装饰器: 在方法成功执行后自动使标签缓存失效。""" + + async def wrapper(self: "TagManager", *args, **kwargs): + result = await func(self, *args, **kwargs) + await self._invalidate_cache() + return result + + return wrapper + + +class TagManager: + """群组标签管理服务。提供对群组标签的注册、解析与维护等操作。""" + + _dynamic_handlers: ClassVar[dict[str, HandlerInfo]] = {} + + def add_field_rule(self, name: str, db_field: str, value_type: type): + """ + 一个便捷的快捷方式,用于快速创建一个基于 `GroupConsole` 模型字段的规则。 + 它在内部使用 `register_rule`。 + """ + from arclet.alconna import CommandMeta + + alc = Alconna( + name, + Args["op", str]["value", value_type], + meta=CommandMeta( + fuzzy_match=True, + compact=False, + ), + ) + + handler = partial(self._generic_field_handler, db_field=db_field) + + self.register_rule(alc)(handler) + + logger.debug(f"已添加字段规则: '{name}' -> {db_field} ({value_type.__name__})") + + async def _generic_field_handler( + self, db_field: str, op: str, value: Any + ) -> QueryResult: + """所有通过 add_field_rule 添加的规则共享的处理器。""" + op_map = {">": "__gt", ">=": "__gte", "<": "__lt", "<=": "__lte", "=": ""} + op_lower = op.lower() + + if op_lower == "contains": + op_suffix = "__iposix_regex" + elif op_lower == "in": + op_suffix = "__in" + value = [v.strip() for v in str(value).split(",")] + elif op == "!=": + return QueryResult(q_object=~Q(**{db_field: value})) + elif op in op_map: + op_suffix = op_map[op] + else: + raise RuleExecutionError(f"字段 '{db_field}' 不支持操作符: {op}") + + q_kwargs: dict[str, Any] = { + f"{db_field}{op_suffix}" if op_suffix else db_field: value + } + return QueryResult(q_object=Q(**q_kwargs)) + + def register_rule(self, alconna: Alconna): + """ + 装饰器:注册一个完全自定义的规则处理器及其语法定义(Alconna)。 + """ + + def decorator(handler: Callable[..., Coroutine[Any, Any, RuleExecutionResult]]): + name = alconna.command + if name in self._dynamic_handlers: + logger.warning(f"动态标签规则 '{name}' 已被注册,将被覆盖。") + self._dynamic_handlers[name] = HandlerInfo(func=handler, alconna=alconna) + logger.debug(f"已注册动态标签规则: '{name}'") + return handler + + return decorator + + async def _invalidate_cache(self): + """辅助函数,用于清除标签相关的缓存,确保数据一致性。""" + cache = Cache(Cache.MEMORY, namespace="tag_service") + await cache.clear() + logger.debug("已清除所有群组标签缓存。") + + @invalidate_on_change + async def create_tag( + self, + name: str, + is_blacklist: bool = False, + description: str | None = None, + group_ids: list[str] | None = None, + tag_type: str = "STATIC", + dynamic_rule: dict | str | None = None, + ) -> GroupTag: + """ + 创建新的群组标签。 + + 参数: + name: 标签名称。 + is_blacklist: 是否为黑名单标签,黑名单标签会在最终结果中剔除关联群组。 + description: 标签描述信息。 + group_ids: 需要关联的静态群组 ID 列表,动态标签必须留空。 + tag_type: 标签类型,支持 ``STATIC`` 或 ``DYNAMIC``。 + dynamic_rule: 动态标签所使用的规则配置。 + + 返回: + 新创建的 ``GroupTag`` 实例。 + """ + if tag_type == "DYNAMIC" and group_ids: + raise ValueError("动态标签不能在创建时关联静态群组。") + if tag_type == "STATIC" and dynamic_rule: + raise ValueError("静态标签不能设置动态规则。") + async with in_transaction(): + tag = await GroupTag.create( + name=name, + is_blacklist=is_blacklist, + description=description, + tag_type=tag_type, + dynamic_rule=dynamic_rule, + ) + if group_ids: + await GroupTagLink.bulk_create( + [GroupTagLink(tag=tag, group_id=gid) for gid in group_ids] + ) + return tag + + @invalidate_on_change + async def delete_tag(self, name: str) -> bool: + """ + 删除指定标签。 + + 参数: + name: 标签名称。 + + 返回: + ``True`` 表示删除成功,``False`` 表示标签不存在。 + """ + deleted_count = await GroupTag.filter(name=name).delete() + return deleted_count > 0 + + @invalidate_on_change + async def add_groups_to_tag(self, name: str, group_ids: list[str]) -> int: # type: ignore + """ + 向静态标签追加群组关联。 + """ + tag = await GroupTag.get_or_none(name=name) + if not tag: + raise ValueError(f"标签 '{name}' 不存在。") + if tag.tag_type == "DYNAMIC": + raise ValueError("不能向动态标签手动添加群组。") + + await GroupTagLink.bulk_create( + [GroupTagLink(tag=tag, group_id=gid) for gid in group_ids], + ignore_conflicts=True, + ) + return len(group_ids) + + @invalidate_on_change + async def remove_groups_from_tag(self, name: str, group_ids: list[str]) -> int: + """从静态标签移除指定群组。""" + tag = await GroupTag.get_or_none(name=name) + if not tag: + return 0 + if tag.tag_type == "DYNAMIC": + raise ValueError("不能从动态标签手动移除群组。") + deleted_count = await GroupTagLink.filter( + tag=tag, group_id__in=group_ids + ).delete() + return deleted_count + + async def list_tags_with_counts(self) -> list[dict]: + """列出所有标签及其关联的群组数量。""" + tags = await GroupTag.all().prefetch_related("groups") + return [ + { + "name": tag.name, + "description": tag.description, + "is_blacklist": tag.is_blacklist, + "tag_type": tag.tag_type, + "group_count": len(tag.groups), + } + for tag in tags + ] + + async def get_tag_details(self, name: str, bot: Bot | None = None) -> dict | None: + """ + 获取标签的完整信息,包括基础属性、静态群组与动态解析结果。 + + 参数: + name: 标签名称。 + bot: 可选的 ``Bot`` 实例,用于在动态标签下获取实时群组信息。 + + 返回: + 包含标签详情的字典;若标签不存在则返回 ``None``。 + """ + tag = await GroupTag.get_or_none(name=name).prefetch_related("groups") + if not tag: + return None + + final_group_ids = await self.resolve_tag_to_group_ids(name, bot=bot) + resolved_groups: list[tuple[str, str]] = [] + if final_group_ids: + groups_from_db = await GroupConsole.filter( + group_id__in=final_group_ids + ).all() + resolved_groups = [(g.group_id, g.group_name) for g in groups_from_db] + + return { + "name": tag.name, + "description": tag.description, + "is_blacklist": tag.is_blacklist, + "tag_type": tag.tag_type, + "dynamic_rule": tag.dynamic_rule, + "groups": [link.group_id for link in tag.groups], + "resolved_groups": resolved_groups, + } + + async def _execute_rule( + self, rule_str: str, bot: Bot | None + ) -> RuleExecutionResult: + """使用Alconna解析并执行单个规则。""" + rule_str = " ".join(rule_str.split()) + + parts = rule_str.strip().split(maxsplit=1) + if not parts: + raise RuleExecutionError("规则字符串不能为空") + + rule_name = parts[0] + + handler_info = self._dynamic_handlers.get(rule_name) + if not handler_info: + available_rules = ", ".join(sorted(self._dynamic_handlers.keys())) + raise RuleExecutionError( + f"未知的规则名称: '{rule_name}'\n可用规则: {available_rules}" + ) + + try: + arparma = handler_info.alconna.parse(rule_str) + if not arparma.matched: + error_msg = ( + str(arparma.error_info) if arparma.error_info else "未知语法错误" + ) + + args_info = [] + if handler_info.alconna.args: + for arg in handler_info.alconna.args.argument: + arg_name = arg.name + arg_type = getattr(arg.value, "origin", arg.value) + type_name = getattr(arg_type, "__name__", str(arg_type)) + args_info.append(f"<{arg_name}:{type_name}>") + + expected_format = ( + f"{rule_name} {' '.join(args_info)}" if args_info else rule_name + ) + + example = "" + if rule_name in ["member_count", "level"]: + example = f"\n示例: {rule_name} > 100" + elif rule_name in ["status", "is_super"]: + example = f"\n示例: {rule_name} = true" + elif rule_name == "group_name": + example = f"\n示例: {rule_name} contains 测试" + + raise RuleExecutionError( + f"规则 '{rule_name}' 参数错误: {error_msg}\n" + f"期望格式: {expected_format}{example}" + ) + + func_to_check = ( + handler_info.func.func + if isinstance(handler_info.func, partial) + else handler_info.func + ) + + extra_kwargs = {} + if "bot" in getattr(func_to_check, "__annotations__", {}): + extra_kwargs["bot"] = bot + + result = await arparma.call(handler_info.func, **extra_kwargs) + + if not isinstance(result, RuleExecutionResult): + raise TypeError( + f"处理器 '{rule_name}' 返回了不支持的类型 '{type(result)}'。 " + "必须返回 QueryResult, IDSetResult 或 ErrorResult。" + ) + return result + + except RuleExecutionError: + raise + except Exception as e: + raise RuleExecutionError(f"执行规则 '{rule_name}' 时发生内部错误: {e}") + + async def _resolve_dynamic_tag( + self, rule: dict | str, bot: Bot | None = None + ) -> set[str]: + """根据动态规则解析符合条件的群组 ID 集合。""" + if isinstance(rule, dict): + raise RuleExecutionError("动态规则必须是字符串格式。") + + final_ids: set[str] = set() + or_clauses = [part.strip() for part in rule.split(" or ")] + + for or_clause in or_clauses: + current_and_q = Q() + current_and_ids: set[str] | None = None + + and_rules = [part.strip() for part in or_clause.split(" and ")] + for simple_rule in and_rules: + try: + result = await self._execute_rule(simple_rule, bot) + if isinstance(result, QueryResult): + current_and_q &= result.q_object + elif isinstance(result, IDSetResult): + if current_and_ids is None: + current_and_ids = result.group_ids + else: + current_and_ids.intersection_update(result.group_ids) + elif isinstance(result, ErrorResult): + raise RuleExecutionError(result.message) + + except Exception as e: + raise RuleExecutionError( + f"解析规则 '{simple_rule}' 时失败: {e}" + ) from e + + ids_from_q: set[str] | None = None + if current_and_q.children: + q_filtered_groups = await GroupConsole.filter( + current_and_q + ).values_list("group_id", flat=True) + ids_from_q = {str(gid) for gid in q_filtered_groups} + + if ids_from_q is not None: + if current_and_ids is None: + clause_result_ids = ids_from_q + else: + clause_result_ids = current_and_ids.intersection(ids_from_q) + else: + if current_and_ids is None: + clause_result_ids = set() + else: + clause_result_ids = current_and_ids + + final_ids.update(clause_result_ids) + + if bot: + bot_groups, _ = await PlatformUtils.get_group_list(bot) + bot_group_ids = {g.group_id for g in bot_groups if g.group_id} + final_ids.intersection_update(bot_group_ids) + + return final_ids + + @cached(ttl=300, namespace="tag_service") + async def resolve_tag_to_group_ids( + self, name: str, bot: Bot | None = None + ) -> list[str]: + """ + 核心解析方法:根据标签名解析出最终的群组ID列表 + + 参数: + name: 需要解析的标签名称,特殊值 ``@all`` 表示所有群。 + bot: 可选的 ``Bot`` 实例,用于拉取最新的群信息。 + + 返回: + 标签对应的群组 ID 列表。当标签不存在或无法解析时返回空列表。 + """ + if name == "@all": + if bot: + all_groups, _ = await PlatformUtils.get_group_list(bot) + return [str(g.group_id) for g in all_groups if g.group_id] + else: + all_group_ids = await GroupConsole.all().values_list( + "group_id", flat=True + ) + return [str(gid) for gid in all_group_ids] + + tag = await GroupTag.get_or_none(name=name).prefetch_related("groups") + if not tag: + return [] + + associated_groups: set[str] = set() + if tag.tag_type == "STATIC": + associated_groups = {str(link.group_id) for link in tag.groups} + elif tag.tag_type == "DYNAMIC": + if not tag.dynamic_rule or not isinstance(tag.dynamic_rule, dict | str): + return [] + dynamic_ids = await self._resolve_dynamic_tag(tag.dynamic_rule, bot) + associated_groups = {str(gid) for gid in dynamic_ids} + else: + associated_groups = {str(link.group_id) for link in tag.groups} + + if tag.is_blacklist: + all_groups_query = GroupConsole.all() + if bot: + bot_groups, _ = await PlatformUtils.get_group_list(bot) + bot_group_ids = {str(g.group_id) for g in bot_groups if g.group_id} + if bot_group_ids: + all_groups_query = all_groups_query.filter( + group_id__in=bot_group_ids + ) + else: + return [] + + all_relevant_group_ids_from_db = await all_groups_query.values_list( + "group_id", flat=True + ) + all_relevant_group_ids = { + str(gid) for gid in all_relevant_group_ids_from_db + } + + return list(all_relevant_group_ids - associated_groups) + else: + return list(associated_groups) + + @invalidate_on_change + async def rename_tag(self, old_name: str, new_name: str) -> GroupTag: + """重命名已有标签""" + if await GroupTag.exists(name=new_name): + raise IntegrityError(f"标签 '{new_name}' 已存在。") + tag = await GroupTag.get(name=old_name) + tag.name = new_name + await tag.save(update_fields=["name"]) + return tag + + @invalidate_on_change + async def update_tag_attributes( + self, + name: str, + description: str | None = None, + is_blacklist: bool | None = None, + dynamic_rule: dict | str | None = None, + ) -> GroupTag: + """ + 局部更新标签属性。 + + 参数: + name: 标签名称。 + description: 可选的新描述。 + is_blacklist: 可选的新黑名单标记。 + dynamic_rule: 可选的新动态规则配置。 + + 返回: + 更新后的 ``GroupTag`` 实例。 + """ + tag = await GroupTag.get(name=name) + update_fields = [] + if dynamic_rule is not None: + if tag.tag_type != "DYNAMIC": + raise ValueError("只能为动态标签更新规则。") + tag.dynamic_rule = dynamic_rule # type: ignore + update_fields.append("dynamic_rule") + if description is not None: + tag.description = description + update_fields.append("description") + if is_blacklist is not None: + tag.is_blacklist = is_blacklist + update_fields.append("is_blacklist") + + if update_fields: + await tag.save(update_fields=update_fields) + return tag + + @invalidate_on_change + async def set_groups_for_tag(self, name: str, group_ids: list[str]) -> int: + """ + 覆盖设置静态标签的群组列表。 + + 参数: + name: 标签名称。 + group_ids: 需要绑定的群组 ID 列表。 + + 返回: + 设置成功后的群组数量。 + """ + tag = await GroupTag.get(name=name) + if tag.tag_type == "DYNAMIC": + raise ValueError("不能为动态标签设置静态群组列表。") + async with in_transaction(): + await GroupTagLink.filter(tag=tag).delete() + await GroupTagLink.bulk_create( + [GroupTagLink(tag=tag, group_id=gid) for gid in group_ids], + ignore_conflicts=True, + ) + return len(group_ids) + + @invalidate_on_change + async def clear_all_tags(self) -> int: + """删除所有标签,并清空缓存。""" + deleted_count = await GroupTag.all().delete() + return deleted_count diff --git a/zhenxun/services/tags/models.py b/zhenxun/services/tags/models.py new file mode 100644 index 00000000..b62d9214 --- /dev/null +++ b/zhenxun/services/tags/models.py @@ -0,0 +1,41 @@ +""" +动态标签的规则执行结果模型。 +""" + +from abc import ABC + +from pydantic import BaseModel +from tortoise.expressions import Q + + +class RuleExecutionError(ValueError): + """在规则执行期间,由处理器返回的、可向用户展示的错误。""" + + pass + + +class RuleExecutionResult(BaseModel, ABC): + """规则执行结果的抽象基类。""" + + pass + + +class QueryResult(RuleExecutionResult): + """表示数据库查询条件的结果。""" + + q_object: Q + + class Config: + arbitrary_types_allowed = True + + +class IDSetResult(RuleExecutionResult): + """表示一组群组ID的结果。""" + + group_ids: set[str] + + +class ErrorResult(RuleExecutionResult): + """表示一个可向用户显示的错误。""" + + message: str diff --git a/zhenxun/ui/models/core/markdown.py b/zhenxun/ui/models/core/markdown.py index 4eabd06f..baab8ba0 100644 --- a/zhenxun/ui/models/core/markdown.py +++ b/zhenxun/ui/models/core/markdown.py @@ -192,11 +192,15 @@ class MarkdownData(ContainerComponent): yield from find_components_recursive(self.elements) async def get_extra_css(self, context: Any) -> str: + css_parts = [] + if self.component_css: + css_parts.append(self.component_css) + if self.css_path: css_file = Path(self.css_path) if css_file.is_file(): async with aiofiles.open(css_file, encoding="utf-8") as f: - return await f.read() + css_parts.append(await f.read()) else: logger.warning(f"Markdown自定义CSS文件不存在: {self.css_path}") else: @@ -206,5 +210,6 @@ class MarkdownData(ContainerComponent): ) if css_path and css_path.exists(): async with aiofiles.open(css_path, encoding="utf-8") as f: - return await f.read() - return "" + css_parts.append(await f.read()) + + return "\n".join(css_parts) diff --git a/zhenxun/utils/pydantic_compat.py b/zhenxun/utils/pydantic_compat.py index 5bf3c19e..603958c3 100644 --- a/zhenxun/utils/pydantic_compat.py +++ b/zhenxun/utils/pydantic_compat.py @@ -27,6 +27,7 @@ __all__ = [ "model_copy", "model_dump", "model_json_schema", + "model_validate", "parse_as", ] @@ -44,6 +45,16 @@ def model_copy( return model.copy(update=update_dict, deep=deep) +def model_validate(model_class: type[T], obj: Any) -> T: + """ + Pydantic `model_validate` (v2) 与 `parse_obj` (v1) 的兼容函数。 + """ + if PYDANTIC_V2: + return model_class.model_validate(obj) + else: + return model_class.parse_obj(obj) + + if PYDANTIC_V2: from pydantic import computed_field as compat_computed_field else: diff --git a/zhenxun/utils/time_utils.py b/zhenxun/utils/time_utils.py index f478625d..dcdf0edf 100644 --- a/zhenxun/utils/time_utils.py +++ b/zhenxun/utils/time_utils.py @@ -48,13 +48,13 @@ class TimeUtils: @classmethod def parse_time_string(cls, time_str: str) -> int: """ - 将带有单位的时间字符串 (e.g., "10s", "5m", "1h") 解析为总秒数。 + 将带有单位的时间字符串 (e.g., "10s", "5m", "1h", "1d") 解析为总秒数。 """ time_str = time_str.lower().strip() - match = re.match(r"^(\d+)([smh])$", time_str) + match = re.match(r"^(\d+)([smhd])$", time_str) if not match: raise ValueError( - f"无效的时间格式: '{time_str}'。请使用如 '30s', '10m', '2h' 的格式。" + f"无效的时间格式: '{time_str}'。请使用如 '30s', '10m', '2h', '1d'的格式" ) value, unit = int(match.group(1)), match.group(2) @@ -65,8 +65,34 @@ class TimeUtils: return value * 60 if unit == "h": return value * 3600 + if unit == "d": + return value * 86400 return 0 + @classmethod + def parse_interval_to_dict(cls, interval_str: str) -> dict: + """ + 将时间间隔字符串解析为 APScheduler 的 interval 触发器所需的字典。 + """ + time_str_lower = interval_str.lower().strip() + match = re.match(r"^(\d+)([smhd])$", time_str_lower) + if not match: + raise ValueError( + "时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。" + ) + + value, unit = int(match.group(1)), match.group(2) + + if unit == "s": + return {"seconds": value} + if unit == "m": + return {"minutes": value} + if unit == "h": + return {"hours": value} + if unit == "d": + return {"days": value} + return {} + @classmethod def format_duration(cls, seconds: float) -> str: """