mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2025-12-14 21:52:56 +08:00
Compare commits
8 Commits
90cd5f473f
...
32dc0e3be8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32dc0e3be8 | ||
|
|
b5d6fe30aa | ||
|
|
809b754bb6 | ||
|
|
c839b44256 | ||
|
|
70bde00757 | ||
|
|
eb6d90ae88 | ||
|
|
4b8013d2d6 | ||
|
|
d528711641 |
@ -10,6 +10,9 @@ SESSION_EXPIRE_TIMEOUT=00:00:30
|
||||
|
||||
ALCONNA_USE_COMMAND_START=True
|
||||
|
||||
# ws连接密钥,若bot能被公网访问则建议打开该注释并设置该配置项
|
||||
# ONEBOT_ACCESS_TOKEN=""
|
||||
|
||||
# 全局图片统一使用bytes发送,当真寻与协议端不在同一服务器上时为True
|
||||
IMAGE_TO_BYTES = True
|
||||
|
||||
@ -29,6 +32,7 @@ DB_URL = ""
|
||||
|
||||
# NONE: 不使用缓存, MEMORY: 使用内存缓存, REDIS: 使用Redis缓存
|
||||
CACHE_MODE = NONE
|
||||
|
||||
# REDIS配置,使用REDIS替换Cache内存缓存
|
||||
# REDIS地址
|
||||
# REDIS_HOST = "127.0.0.1"
|
||||
@ -86,4 +90,4 @@ PORT = 8080
|
||||
# '
|
||||
|
||||
# application_commands的{"*": ["*"]}代表将全部应用命令注册为全局应用命令
|
||||
# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册
|
||||
# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册
|
||||
|
||||
@ -7,7 +7,7 @@ ci:
|
||||
autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks"
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.2
|
||||
rev: v0.14.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = ([], [], [])
|
||||
|
||||
@ -74,8 +74,8 @@ async def _(matcher: Matcher, message: UniMsg, session: EventSession):
|
||||
message_list.append(image)
|
||||
message_list.append(
|
||||
"桀桀桀,预判到会有 '笨蛋' 把功能名称当命令用,特地前来嘲笑!"
|
||||
f"但还是好心来帮帮你啦!\n请at我发送 '帮助{plugin.name}' 或者"
|
||||
f" '帮助{plugin.id}' 来获取该功能帮助!"
|
||||
f"但还是好心来帮帮你啦!\n请at我发送 '帮助 {plugin.name}' 或者"
|
||||
f" '帮助 {plugin.id}' 来获取该功能帮助!"
|
||||
)
|
||||
logger.info("检测到功能名称当命令使用,已发送帮助信息", "功能帮助", session=session)
|
||||
await MessageUtils.build_message(message_list).send(reply_to=True)
|
||||
|
||||
@ -263,10 +263,9 @@ class StoreManager:
|
||||
"""安装插件
|
||||
|
||||
参数:
|
||||
github_url: 仓库地址
|
||||
module_path: 模块路径
|
||||
is_dir: 是否是文件夹
|
||||
plugin_info: 插件信息
|
||||
is_external: 是否是外部仓库
|
||||
source: 源
|
||||
"""
|
||||
repo_type = RepoType.GITHUB if is_external else None
|
||||
if source == "ali":
|
||||
|
||||
@ -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"`)。
|
||||
- **删除**: `定时任务 删除 <ID>` (别名: `del`, `rm`, `remove`, `关闭`, `取消`)
|
||||
- **暂停**: `定时任务 暂停 <ID>` (别名: `pause`)
|
||||
- **恢复**: `定时任务 恢复 <ID>` (别名: `resume`)
|
||||
- **执行**: `定时任务 执行 <ID>` (别名: `trigger`, `run`)
|
||||
- **更新**: `定时任务 更新 <ID>` (别名: `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 "<YYYY-MM-DD HH:MM:SS>"` (例: `--date "2024-01-01 08:00:00"`)
|
||||
- `--daily "<HH:MM>"` (例: `--daily "08:30"`)
|
||||
|
||||
📝 时间选项 (三选一):
|
||||
--cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *"
|
||||
--interval <时间间隔> # 例: --interval 30m, 2h, 10s
|
||||
--date "<YYYY-MM-DD HH:MM:SS>" # 例: --date "2024-01-01 08:00:00"
|
||||
--daily "<HH:MM>" # 例: --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(),
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
314
zhenxun/builtin_plugins/scheduler_admin/data_source.py
Normal file
314
zhenxun/builtin_plugins/scheduler_admin/data_source.py
Normal file
@ -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()
|
||||
370
zhenxun/builtin_plugins/scheduler_admin/dependencies.py
Normal file
370
zhenxun/builtin_plugins/scheduler_admin/dependencies.py
Normal file
@ -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 选项指定目标。"
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -175,7 +175,7 @@ class SignManage:
|
||||
impression_added = (secrets.randbelow(99) + 1) / 100
|
||||
rand = random.random()
|
||||
add_probability = float(user.add_probability)
|
||||
specify_probability = user.specify_probability
|
||||
specify_probability = float(user.specify_probability)
|
||||
if rand + add_probability > 0.97 or rand < specify_probability:
|
||||
impression_added *= 2
|
||||
await SignUser.sign(user, impression_added, session.self_id, platform)
|
||||
|
||||
@ -22,9 +22,9 @@ from .config import (
|
||||
lik2relation,
|
||||
)
|
||||
|
||||
assert (
|
||||
len(level2attitude) == len(lik2level) == len(lik2relation)
|
||||
), "好感度态度、等级、关系长度不匹配!"
|
||||
assert len(level2attitude) == len(lik2level) == len(lik2relation), (
|
||||
"好感度态度、等级、关系长度不匹配!"
|
||||
)
|
||||
|
||||
AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160"
|
||||
|
||||
|
||||
413
zhenxun/builtin_plugins/superuser/tag_manage.py
Normal file
413
zhenxun/builtin_plugins/superuser/tag_manage.py
Normal file
@ -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|dynamic>`: 标签类型,默认为 `static`。
|
||||
- `static`: 静态标签,需手动关联群组。
|
||||
- `dynamic`: 动态标签,根据规则自动匹配。
|
||||
- `-g <群号...>`: **(静态)** 初始关联的群组ID。
|
||||
- `--rule "<规则>"`: **(动态)** 定义动态规则,**规则必须用引号包裹**。
|
||||
- `--desc "<描述>"`: 为标签添加描述。
|
||||
- `--blacklist`: **(静态)** 将标签设为黑名单(排除)模式。
|
||||
|
||||
- **`tag edit <标签名> [操作...]`**
|
||||
- 编辑一个已存在的标签。
|
||||
- **通用操作**:
|
||||
- `--rename <新名>`: 重命名标签。
|
||||
- `--desc "<描述>"`: 更新描述。
|
||||
- `--mode <white|black>`: 切换为白名单/黑名单模式。
|
||||
- **静态标签操作**:
|
||||
- `--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()
|
||||
54
zhenxun/models/group_tag.py
Normal file
54
zhenxun/models/group_tag.py
Normal file
@ -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")
|
||||
@ -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 = "通用定时任务定义表"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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}")
|
||||
482
zhenxun/services/scheduler/engine.py
Normal file
482
zhenxun/services/scheduler/engine.py
Normal file
@ -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)
|
||||
@ -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)
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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]:
|
||||
|
||||
@ -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()
|
||||
151
zhenxun/services/scheduler/types.py
Normal file
151
zhenxun/services/scheduler/types.py
Normal file
@ -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
|
||||
11
zhenxun/services/tags/__init__.py
Normal file
11
zhenxun/services/tags/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""
|
||||
标签服务入口,提供 ``TagManager`` 实例并加载内置规则。
|
||||
"""
|
||||
|
||||
from .manager import TagManager
|
||||
|
||||
tag_manager = TagManager()
|
||||
|
||||
from . import filters # noqa: F401
|
||||
|
||||
__all__ = ["tag_manager"]
|
||||
11
zhenxun/services/tags/filters.py
Normal file
11
zhenxun/services/tags/filters.py
Normal file
@ -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)
|
||||
527
zhenxun/services/tags/manager.py
Normal file
527
zhenxun/services/tags/manager.py
Normal file
@ -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
|
||||
41
zhenxun/services/tags/models.py
Normal file
41
zhenxun/services/tags/models.py
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -263,6 +263,18 @@ class AsyncHttpx:
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPStatusError):
|
||||
status = getattr(e.response, "status_code", "?")
|
||||
try:
|
||||
body_text = getattr(e.response, "text", None)
|
||||
if body_text is not None and len(body_text) > 2000:
|
||||
body_text = body_text[:2000] + "...(truncated)"
|
||||
except Exception:
|
||||
body_text = "<unavailable>"
|
||||
logger.debug(
|
||||
f"请求失败: {url} {status} {body_text}",
|
||||
"AsyncHttpx:FallbackExecutor",
|
||||
)
|
||||
exceptions.append(e)
|
||||
if url != url_list[-1]:
|
||||
logger.warning(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user