From e0c55545ec63a1b78542dba0a57419180836c80e Mon Sep 17 00:00:00 2001 From: passthem Date: Fri, 24 Oct 2025 05:08:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=AD=A4=E6=96=B9=E6=8F=90?= =?UTF-8?q?=E9=86=92=E7=9A=84=20CURD=20=E5=92=8C=20ntfy=20=E8=81=94?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- konabot/common/longtask.py | 26 +- konabot/docs/user/ntfy.txt | 15 + konabot/docs/user/删除提醒.txt | 8 + konabot/docs/user/提醒我.txt | 15 + konabot/docs/user/查询提醒.txt | 9 + konabot/plugins/simple_notify/__init__.py | 371 +++++++++++++--------- poetry.lock | 19 +- pyproject.toml | 1 + 8 files changed, 298 insertions(+), 166 deletions(-) create mode 100644 konabot/docs/user/ntfy.txt create mode 100644 konabot/docs/user/删除提醒.txt create mode 100644 konabot/docs/user/提醒我.txt create mode 100644 konabot/docs/user/查询提醒.txt diff --git a/konabot/common/longtask.py b/konabot/common/longtask.py index b64e2e8..b988e36 100644 --- a/konabot/common/longtask.py +++ b/konabot/common/longtask.py @@ -66,7 +66,7 @@ class LongTaskTarget(BaseModel): } BOT_CLASS={bot.__class__.__name__}" ) return False - if self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX): + if self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX) or not self.channel_id.strip(): # 私聊模式 await bot.send_private_msg( user_id=int(self.target_id), @@ -119,18 +119,18 @@ class LongTask(BaseModel): target: LongTaskTarget callback: str deadline: datetime.datetime - canceled: bool = False _aio_task: asynkio.Task | None = None async def run(self): now = datetime.datetime.now() - if self.deadline < now and not self.canceled: + if self.deadline < now: await self._run_task() return await asynkio.sleep((self.deadline - now).total_seconds()) - if self.canceled: - return + async with longtask_data() as data: + if self.uuid not in data.to_handle[self.callback]: + return await self._run_task() async def _run_task(self): @@ -140,11 +140,7 @@ class LongTask(BaseModel): f"Callback {self.callback} 未曾被注册,但是被期待调用,已忽略" ) async with longtask_data() as datafile: - datafile.to_handle[self.callback] = [ - t - for t in datafile.to_handle.get(self.callback, []) - if t.uuid != self.uuid - ] + del datafile.to_handle[self.callback][self.uuid] datafile.unhandled.setdefault(self.callback, []).append(self) return @@ -155,9 +151,7 @@ class LongTask(BaseModel): except Exception as e: logger.exception(e) async with longtask_data() as datafile: - datafile.to_handle[self.callback] = [ - t for t in datafile.to_handle[self.callback] if t.uuid != self.uuid - ] + del datafile.to_handle[self.callback][self.uuid] if not success: datafile.unhandled.setdefault(self.callback, []).append(self) logger.info( @@ -181,7 +175,7 @@ class LongTask(BaseModel): class LongTaskModuleData(BaseModel): - to_handle: dict[str, list[LongTask]] + to_handle: dict[str, dict[str, LongTask]] unhandled: dict[str, list[LongTask]] @@ -279,7 +273,7 @@ async def create_longtask( await task.start() async with longtask_data() as d: - d.to_handle.setdefault(handler, []).append(task) + d.to_handle.setdefault(handler, {})[task.uuid] = task return task @@ -290,7 +284,7 @@ async def init_longtask(): async with longtask_data() as data: for v in data.to_handle.values(): - for t in v: + for t in v.values(): await t.start() counter += 1 req.add(t.callback) diff --git a/konabot/docs/user/ntfy.txt b/konabot/docs/user/ntfy.txt new file mode 100644 index 0000000..88adcd2 --- /dev/null +++ b/konabot/docs/user/ntfy.txt @@ -0,0 +1,15 @@ +指令介绍 + ntfy - 配置使用 ntfy 来更好地为你通知此方 BOT 代办 + +指令示例 + `ntfy 创建` + 创建一个随机的 ntfy 订阅主题来提醒代办,此方 Bot 将会给你使用指引。你可以前往 https://ntfy.sh/ 官网下载 ntfy APP,或者使用网页版 ntfy。 + + `ntfy 创建 kagami-notice` + 创建一个名字含有 kagami-notice 的 ntfy 订阅主题 + + `ntfy 删除` + 清除并不再使用 ntfy 向你通知 + +另见 + 提醒我(1) 查询提醒(1) 删除提醒(1) diff --git a/konabot/docs/user/删除提醒.txt b/konabot/docs/user/删除提醒.txt new file mode 100644 index 0000000..189cc6c --- /dev/null +++ b/konabot/docs/user/删除提醒.txt @@ -0,0 +1,8 @@ +指令介绍 + 删除提醒 - 删除在`查询提醒(1)`中查到的提醒 + +指令示例 + `删除提醒 1` 在查询提醒后,删除编号为 1 的提醒 + +另见 + 提醒我(1) 查询提醒(1) ntfy(1) diff --git a/konabot/docs/user/提醒我.txt b/konabot/docs/user/提醒我.txt new file mode 100644 index 0000000..5ff9821 --- /dev/null +++ b/konabot/docs/user/提醒我.txt @@ -0,0 +1,15 @@ +指令介绍 + 提醒我 - 在指定的时间提醒人事项的工具 + +使用示例 + `下午五点提醒我吃饭` + 创建一个下午五点的提醒,提醒你吃饭 + + `两分钟后提醒我睡觉` + 创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉 + + `2026年4月25日20点整提醒我生日快乐` + 创建一个指定日期和时间的提醒 + +另见 + 查询提醒(1) 删除提醒(1) ntfy(1) diff --git a/konabot/docs/user/查询提醒.txt b/konabot/docs/user/查询提醒.txt new file mode 100644 index 0000000..418585e --- /dev/null +++ b/konabot/docs/user/查询提醒.txt @@ -0,0 +1,9 @@ +指令介绍 + 查询提醒 - 查询已经创建的提醒 + +指令格式 + `查询提醒` 查询提醒 + `查询提醒 2` 查询第二页提醒 + +另见 + 提醒我(1) 删除提醒(1) ntfy(1) diff --git a/konabot/plugins/simple_notify/__init__.py b/konabot/plugins/simple_notify/__init__.py index 6e85894..9960c0c 100644 --- a/konabot/plugins/simple_notify/__init__.py +++ b/konabot/plugins/simple_notify/__init__.py @@ -1,25 +1,24 @@ +import aiohttp import asyncio as asynkio import datetime +from math import ceil from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, Literal +import nanoid import nonebot import ptimeparse from loguru import logger -from nonebot import on_message -from nonebot.adapters import Event -from nonebot.adapters.console import Bot as ConsoleBot -from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent -from nonebot.adapters.discord import Bot as DiscordBot -from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent -from nonebot.adapters.onebot.v11 import Bot as OnebotV11Bot -from nonebot.adapters.onebot.v11.event import ( - GroupMessageEvent as OnebotV11GroupMessageEvent, -) -from nonebot.adapters.onebot.v11.event import MessageEvent as OnebotV11MessageEvent -from nonebot_plugin_alconna import UniMessage, UniMsg +from nonebot import get_plugin_config, on_message +from nonebot.adapters import Bot, Event +from nonebot.adapters.onebot.v11 import Bot as OBBot +from nonebot.adapters.console import Bot as CBot +from nonebot.adapters.discord import Bot as DCBot +from nonebot_plugin_alconna import Alconna, Args, Subcommand, UniMessage, UniMsg, on_alconna from pydantic import BaseModel +from konabot.common.longtask import DepLongTaskTarget, LongTask, LongTaskTarget, create_longtask, handle_long_task, longtask_data + evt = on_message() (Path(__file__).parent.parent.parent.parent / "data").mkdir(exist_ok=True) @@ -27,6 +26,14 @@ DATA_FILE_PATH = Path(__file__).parent.parent.parent.parent / "data" / "notify.j DATA_FILE_LOCK = asynkio.Lock() ASYNK_TASKS: set[asynkio.Task[Any]] = set() +LONG_TASK_NAME = "TASK_SIMPLE_NOTIFY" +PAGE_SIZE = 6 + +FMT_STRING = "%Y年%m月%d日 %H:%M:%S" + + +class NotifyMessage(BaseModel): + message: str class Notify(BaseModel): @@ -41,14 +48,64 @@ class Notify(BaseModel): notify_time: datetime.datetime notify_msg: str - def get_str(self): - return f"{self.target}-{self.target_env}-{self.platform}-{self.notify_time}" - class NotifyConfigFile(BaseModel): version: int = 2 notifies: list[Notify] = [] unsent: list[Notify] = [] + notify_channels: dict[str, str] = {} + + +class NotifyPluginConfig(BaseModel): + plugin_notify_enable_ntfy: bool = False + plugin_notify_base_url: str = "" + plugin_notify_access_token: str = "" + plugin_notify_prefix: str = "kona-notice-" + + +config = get_plugin_config(NotifyPluginConfig) + + +async def send_notify_to_ntfy_instance(msg: str, channel: str): + if not config.plugin_notify_enable_ntfy: + return + url = f"{config.plugin_notify_base_url}/{channel}" + + async with aiohttp.ClientSession() as session: + session.headers["Authorization"] = f"Bearer {config.plugin_notify_access_token}" + session.headers["Title"] = "🔔 此方 BOT 提醒" + async with session.post(url, data=msg) as response: + logger.info(f"访问 {url} 的结果是 {response.status}") + + +def _get_bot_of(_type: type[Bot]): + for bot in nonebot.get_bots().values(): + if isinstance(bot, _type): + return bot.self_id + return "" + + +def get_target_from_notify(notify: Notify) -> LongTaskTarget: + if notify.platform == "console": + return LongTaskTarget( + platform="console", + self_id=_get_bot_of(CBot), + channel_id=notify.target_env or "", + target_id=notify.target, + ) + if notify.platform == "discord": + return LongTaskTarget( + platform="discord", + self_id=_get_bot_of(DCBot), + channel_id=notify.target_env or "", + target_id=notify.target, + ) + return LongTaskTarget( + platform="qq", + self_id=_get_bot_of(OBBot), + channel_id=notify.target_env or "", + target_id=notify.target, + ) def load_notify_config() -> NotifyConfigFile: @@ -65,89 +122,8 @@ def save_notify_config(config: NotifyConfigFile): DATA_FILE_PATH.write_text(config.model_dump_json(indent=4)) -async def notify_now(notify: Notify): - if notify.platform == "console": - bot = [b for b in nonebot.get_bots().values() if isinstance(b, ConsoleBot)] - if len(bot) != 1: - logger.warning(f"提醒未成功发送出去:{nonebot.get_bots()} {notify}") - return False - bot = bot[0] - await bot.send_private_message(notify.target, f"代办通知:{notify.notify_msg}") - elif notify.platform == "discord": - bot = [b for b in nonebot.get_bots().values() if isinstance(b, DiscordBot)] - if len(bot) != 1: - logger.warning(f"提醒未成功发送出去:{nonebot.get_bots()} {notify}") - return False - bot = bot[0] - channel = await bot.create_DM(recipient_id=int(notify.target)) - await bot.send_to(channel.id, f"代办通知:{notify.notify_msg}") - elif notify.platform == "qq": - bot = [b for b in nonebot.get_bots().values() if isinstance(b, OnebotV11Bot)] - if len(bot) != 1: - logger.warning(f"提醒未成功发送出去:{nonebot.get_bots()} {notify}") - return False - bot = bot[0] - if notify.target_env is None: - await bot.send_private_msg( - user_id=int(notify.target), - message=cast( - Any, - await UniMessage.text(f"代办通知:{notify.notify_msg}").export( - bot=bot, - ), - ), - ) - else: - await bot.send_group_msg( - group_id=int(notify.target_env), - message=cast( - Any, - await UniMessage() - .at(notify.target) - .text(f" 代办通知:{notify.notify_msg}") - .export(bot=bot), - ), - ) - else: - logger.warning(f"提醒未成功发送出去:{notify}") - return False - return True - - -def create_notify_task(notify: Notify, fail2remove: bool = True): - async def mission(): - begin_time = datetime.datetime.now() - if begin_time < notify.notify_time: - try: - await asynkio.sleep((notify.notify_time - begin_time).total_seconds()) - except asynkio.CancelledError: - logger.debug( - f"代办提醒被信号中止,任务退出 NOTIFY={notify.notify_msg} TIME={notify.notify_time}" - ) - return - else: - logger.warning( - f"期望在 {notify.notify_time} 在平台 {notify.platform} {notify.target_env}" - f" {notify.target} 的代办通知 {notify.notify_msg} 已经超时,将会直接通知!" - ) - res = await notify_now(notify) - if fail2remove or res: - async with DATA_FILE_LOCK: - cfg = load_notify_config() - cfg.notifies = [ - n for n in cfg.notifies if n.get_str() != notify.get_str() - ] - if not res: - cfg.unsent.append(notify) - save_notify_config(cfg) - else: - pass - - return asynkio.create_task(mission()) - - @evt.handle() -async def _(msg: UniMsg, mEvt: Event): +async def _(msg: UniMsg, mEvt: Event, target: DepLongTaskTarget): if mEvt.get_user_id() in nonebot.get_bots(): return @@ -160,62 +136,26 @@ async def _(msg: UniMsg, mEvt: Event): return notify_time, notify_text = segments - # target_time = get_target_time(notify_time) try: - # target_time = ptimeparse.parse(notify_time) target_time = ptimeparse.Parser().parse(notify_time) logger.info(f"从 {notify_time} 解析出了时间:{target_time}") except Exception: logger.info(f"无法从 {notify_time} 中解析出时间") return - # if target_time is None: - # logger.info(f"无法从 {notify_time} 中解析出时间") - # return if not notify_text: return - await DATA_FILE_LOCK.acquire() - cfg = load_notify_config() - - if isinstance(mEvt, ConsoleMessageEvent): - platform = "console" - target = mEvt.get_user_id() - target_env = None - elif isinstance(mEvt, OnebotV11MessageEvent): - platform = "qq" - target = mEvt.get_user_id() - if isinstance(mEvt, OnebotV11GroupMessageEvent): - target_env = str(mEvt.group_id) - else: - target_env = None - elif isinstance(mEvt, DiscordMessageEvent): - platform = "discord" - target = mEvt.get_user_id() - target_env = None - else: - logger.warning(f"Notify 遇到不支持的平台:{type(mEvt).__name__}") - return - - notify = Notify( - platform=platform, - target=target, - target_env=target_env, - notify_time=target_time, - notify_msg=notify_text, + await create_longtask( + LONG_TASK_NAME, + { "message": notify_text }, + target, + target_time, ) - create_notify_task(notify) - cfg.notifies.append(notify) - save_notify_config(cfg) - DATA_FILE_LOCK.release() - - await evt.send( - await UniMessage() - .at(mEvt.get_user_id()) - .text(f" 了解啦!将会在 {notify.notify_time} 提醒你哦~") - .export() + await target.send_message( + UniMessage().text(f"了解啦!将会在 {target_time.strftime(FMT_STRING)} 提醒你哦~") ) - logger.info(f"创建了一条于 {notify.notify_time} 的代办提醒") + logger.info(f"创建了一条于 {target_time} 的代办提醒") driver = nonebot.get_driver() @@ -238,19 +178,152 @@ async def _(): await DATA_FILE_LOCK.acquire() - # tasks: set[asynkio.Task[Any]] = set() cfg = load_notify_config() if cfg.version == 1: logger.info("将配置文件的版本升级为 2") cfg.version = 2 else: - counter = 0 for notify in [*cfg.notifies]: - task = create_notify_task(notify, fail2remove=False) - ASYNK_TASKS.add(task) - task.add_done_callback(lambda self: ASYNK_TASKS.remove(self)) - counter += 1 - logger.info(f"成功创建了 {counter} 条代办事项") + await create_longtask( + handler=LONG_TASK_NAME, + data={ "message": notify.notify_msg }, + target=get_target_from_notify(notify), + deadline=notify.notify_time, + ) + cfg.notifies = [] save_notify_config(cfg) DATA_FILE_LOCK.release() + +@handle_long_task("TASK_SIMPLE_NOTIFY") +async def _(task: LongTask): + message = task.data["message"] + await task.target.send_message( + UniMessage().text(f"代办提醒:{message}") + ) + async with DATA_FILE_LOCK: + data = load_notify_config() + if (chan := data.notify_channels.get(task.target.target_id)) is not None: + await send_notify_to_ntfy_instance(message, chan) + save_notify_config(data) + + +USER_CHECKOUT_TASK_CACHE: dict[str, dict[str, str]] = {} + + +cmd_check_notify_list = on_alconna(Alconna( + "re:(?:我有哪些|查询)(?:提醒|代办)", + Args["page", int, 1] +)) + +@cmd_check_notify_list.handle() +async def _(page: int, target: DepLongTaskTarget): + if page <= 0: + await target.send_message(UniMessage().text("页数应该大于 0 吧")) + return + async with longtask_data() as data: + tasks = data.to_handle.get(LONG_TASK_NAME, {}).values() + tasks = [t for t in tasks if t.target.target_id == target.target_id] + tasks = sorted(tasks, key=lambda t: t.deadline) + pages = ceil(len(tasks) / PAGE_SIZE) + if page > pages: + await target.send_message(UniMessage().text(f"最多也就 {pages} 页啦!")) + tasks = tasks[(page - 1) * PAGE_SIZE: page * PAGE_SIZE] + + message = "你可以输入「删除提醒 序号」来删除一个提醒\n====== 代办清单 ======\n\n" + + to_cache = {} + if len(tasks) == 0: + message += "空空如也\n" + else: + for i, task in enumerate(tasks): + to_cache[str(i + 1)] = task.uuid + message += f"{i + 1}) {task.data['message']}({task.deadline.strftime(FMT_STRING)})\n" + + message += f"\n==== 第 {page} 页,共 {pages} 页 ====" + USER_CHECKOUT_TASK_CACHE[target.target_id] = to_cache + + await target.send_message(UniMessage().text(message)) + + +cmd_remove_task = on_alconna(Alconna( + "re:删除(?:提醒|代办)", + Args["checker", str], +)) + +@cmd_remove_task.handle() +async def _(checker: str, target: DepLongTaskTarget): + if target.target_id not in USER_CHECKOUT_TASK_CACHE: + await target.send_message(UniMessage().text( + "先用「查询提醒」来查询你有哪些提醒吧" + )) + return + if checker not in USER_CHECKOUT_TASK_CACHE[target.target_id]: + await target.send_message(UniMessage().text( + "没有这个任务哦,请检查一下吧" + )) + uuid = USER_CHECKOUT_TASK_CACHE[target.target_id][checker] + async with longtask_data() as data: + if uuid not in data.to_handle[LONG_TASK_NAME]: + await target.send_message(UniMessage().text( + "似乎这个提醒已经发出去了,或者已经被删除" + )) + return + _msg = data.to_handle[LONG_TASK_NAME][uuid].data["message"] + del data.to_handle[LONG_TASK_NAME][uuid] + await target.send_message(UniMessage().text( + f"成功取消了提醒:{_msg}" + )) + + +cmd_notify_channel = on_alconna(Alconna( + "ntfy", + Subcommand("删除", dest="delete"), + Subcommand("创建", Args["notify_id?", str], dest="create"), +), rule=lambda: config.plugin_notify_enable_ntfy) + +@cmd_notify_channel.assign("$main") +async def _(target: DepLongTaskTarget): + await target.send_message(UniMessage.text( + "配置 ntfy 通知:\n\n" + "- ntfy 创建: 启用 ntfy 通知,并为你随机生成一个通知渠道\n" + "- ntfy 删除:禁用 ntfy 通知\n" + )) + +@cmd_notify_channel.assign("create") +async def _(target: DepLongTaskTarget, notify_id: str = ""): + if notify_id == "": + notify_id = nanoid.generate( + alphabet="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-", + size=16, + ) + + channel_name = f"{config.plugin_notify_prefix}{notify_id}" + + async with DATA_FILE_LOCK: + data = load_notify_config() + data.notify_channels[target.target_id] = channel_name + save_notify_config(data) + + await target.send_message(UniMessage.text( + f"了解!将会在 {channel_name} 为你提醒!\n" + "\n" + "食用教程:在你的手机端 / 网页端 ntfy 点击「订阅主题」,选择「使用其他服务器」," + f"服务器填写 {config.plugin_notify_base_url} ,主题名填写 {channel_name}\n" + f"最后点击订阅,就能看到我给你发的消息啦!" + )) + + await send_notify_to_ntfy_instance( + "如果你看到这条消息,说明你已经成功订阅主题!此方 BOT 将会在这里提醒你你的代办!", + channel_name, + ) + + +@cmd_notify_channel.assign("delete") +async def _(target: DepLongTaskTarget): + async with DATA_FILE_LOCK: + data = load_notify_config() + del data.notify_channels[target.target_id] + save_notify_config(data) + await target.send_message(UniMessage.text("ok.")) + diff --git a/poetry.lock b/poetry.lock index 725d7a4..6924f11 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1743,6 +1743,23 @@ type = "legacy" url = "https://pypi.tuna.tsinghua.edu.cn/simple" reference = "mirrors" +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] + +[package.source] +type = "legacy" +url = "https://pypi.tuna.tsinghua.edu.cn/simple" +reference = "mirrors" + [[package]] name = "nepattern" version = "0.7.7" @@ -3807,4 +3824,4 @@ reference = "mirrors" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "78a299c64ba07999fae807300b10a1c622d45b8b387aded5a34d17cf5550e777" +content-hash = "96080ea588b3ac52b19909379585cd647646faf3dce291f8d2b5801a3111c838" diff --git a/pyproject.toml b/pyproject.toml index c515049..8be0415 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)", "qrcode (>=8.2,<9.0)", "ptimeparse (>=0.2.1,<0.3.0)", + "nanoid (>=2.0.0,<3.0.0)", ] [build-system]