Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0483d1d5c | |||
| ae83b66908 | |||
| 6abeb05a18 | |||
| 9b0a0368fa | |||
| 4eac493de4 | |||
| b4e400b626 | |||
| c35ee57976 | |||
| 8edb999050 | |||
| 109a81923f | |||
| 91687fb8c3 | |||
| f889381cce | |||
| 1256055c9d | |||
| 40f35a474e | |||
| 6b01acfa8c | |||
| 09c9d44798 | |||
| 0c4206f461 | |||
| 9fb8fd90dc | |||
| 8c4fa2b5e4 | |||
| fb2c3f1ce2 | |||
| 265415e727 | |||
| 06555b2225 | |||
| f6fd25a41d | |||
| 9f6c70bf0f | |||
| 1c01e49d5d | |||
| 48c719bc33 | |||
| 6bc9f94e83 | |||
| deab2d7b2b | |||
| 2a6abbe0d4 | |||
| 30bdc50024 | |||
| be8b1b9999 | |||
| 43d0a09de2 | |||
| 6e0082c1c9 | |||
| 3b8b060c5b | |||
| 8cfe58c7dd | |||
| f997bf945a | |||
| 0dbe164703 | |||
| 818f2b64ec | |||
| a855c69f61 | |||
| 90ee296f55 | |||
| 915f186955 | |||
| a279e9b510 | |||
| f0a7cd4707 | |||
| c8b599f380 | |||
| 21e996a3b9 | |||
| a68c8bee98 | |||
| 6362ed4a88 | |||
| 7e3611afcd |
@ -1,4 +1,5 @@
|
||||
/.env
|
||||
/.git
|
||||
/data
|
||||
|
||||
__pycache__
|
||||
@ -26,6 +26,14 @@ steps:
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
path: /var/run/docker.sock
|
||||
- name: 在容器中测试插件加载
|
||||
image: docker:dind
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python scripts/test_plugin_load.py
|
||||
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
|
||||
4
.env.test
Normal file
@ -0,0 +1,4 @@
|
||||
ENVIRONMENT=test
|
||||
ENABLE_CONSOLE=false
|
||||
ENABLE_QQ=false
|
||||
ENABLE_DISCORD=false
|
||||
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
/.env
|
||||
/data
|
||||
|
||||
__pycache__
|
||||
24
.vscode/launch.json
vendored
@ -1,24 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "运行 Bot 并调试(自动重载)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "watchfiles",
|
||||
"args": [
|
||||
"bot.main"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}",
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "bot"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
30
.vscode/tasks.json
vendored
@ -1,30 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Poetry: Export requirements.txt (Production)",
|
||||
"type": "shell",
|
||||
"command": "poetry export -f requirements.txt --output requirements.txt --without-hashes",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"detail": "导出生产环境依赖到 requirements.txt"
|
||||
},
|
||||
{
|
||||
"label": "Bot: Run with Auto-reload",
|
||||
"type": "shell",
|
||||
"command": "poetry run watchfiles bot.main",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"detail": "运行 bot 并启用自动重载功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
38
Dockerfile
@ -1,8 +1,38 @@
|
||||
FROM python:3.13-slim
|
||||
# copied from https://www.martinrichards.me/post/python_poetry_docker/
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt --no-deps
|
||||
|
||||
COPY . .
|
||||
RUN pip install poetry
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN python -m poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
||||
|
||||
|
||||
|
||||
FROM base AS runtime
|
||||
|
||||
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY bot.py pyproject.toml .env.prod .env.test ./
|
||||
COPY assets ./assets
|
||||
COPY scripts ./scripts
|
||||
COPY konabot ./konabot
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
CMD [ "python", "bot.py" ]
|
||||
|
||||
@ -65,10 +65,10 @@ code .
|
||||
|
||||
### 运行
|
||||
|
||||
你可以在 VSCode 的「运行与调试」窗口,启动 `运行 Bot 并调试(自动重载)` 任务来启动 Bot,也可以使用命令行手动启动 Bot:
|
||||
使用命令行手动启动 Bot:
|
||||
|
||||
```bash
|
||||
poetry run watchfiles bot.main
|
||||
poetry run watchfiles bot.main konabot
|
||||
```
|
||||
|
||||
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
||||
|
||||
BIN
assets/fonts/HarmonyOS_Sans_SC_Regular.ttf
Normal file
BIN
assets/fonts/LXGWWenKai-Regular.ttf
Normal file
BIN
assets/fonts/NotoColorEmoji-Regular.ttf
Normal file
BIN
assets/fonts/montserrat.otf
Normal file
BIN
assets/img/dice/1.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/img/dice/10.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/img/dice/11.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/img/dice/12.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/img/dice/2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/img/dice/3.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/img/dice/4.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/img/dice/5.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/img/dice/6.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/img/dice/7.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/img/dice/8.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/img/dice/9.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/img/dice/stick.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/img/dice/template.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
assets/img/meme/caoimg1.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/img/meme/dss.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 219 KiB |
BIN
assets/img/meme/mnksay.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
assets/img/meme/ptsay.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
assets/img/meme/suanleba.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
assets/img/meme/tententen.png
Normal file
|
After Width: | Height: | Size: 614 KiB |
0
konabot/__init__.py
Normal file
9
konabot/common/nb/exc.py
Normal file
@ -0,0 +1,9 @@
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
|
||||
class BotExceptionMessage(Exception):
|
||||
def __init__(self, msg: UniMessage | str) -> None:
|
||||
super().__init__()
|
||||
if isinstance(msg, str):
|
||||
msg = UniMessage().text(msg)
|
||||
self.msg = msg
|
||||
159
konabot/common/nb/extract_image.py
Normal file
@ -0,0 +1,159 @@
|
||||
from io import BytesIO
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import PIL.Image
|
||||
from loguru import logger
|
||||
import nonebot
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.adapters import Bot, Event, Message
|
||||
from nonebot.adapters.discord import Bot as DiscordBot
|
||||
from nonebot.adapters.onebot.v11 import Bot as OnebotV11Bot
|
||||
from nonebot.adapters.onebot.v11 import Message as OnebotV11Message
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
|
||||
import nonebot.params
|
||||
from nonebot_plugin_alconna import Image, RefNode, Reply, UniMessage
|
||||
from PIL import UnidentifiedImageError
|
||||
from returns.result import Failure, Result, Success
|
||||
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
|
||||
|
||||
async def download_image_bytes(url: str) -> Result[bytes, str]:
|
||||
# if "/matcha/cache/" in url:
|
||||
# url = url.replace('127.0.0.1', '10.126.126.101')
|
||||
logger.debug(f"开始从 {url} 下载图片")
|
||||
async with httpx.AsyncClient() as c:
|
||||
try:
|
||||
response = await c.get(url)
|
||||
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
|
||||
return Failure(f"HTTPX 模块下载图片时出错:{e}")
|
||||
except httpx.ConnectTimeout:
|
||||
return Failure("下载图片失败了,网络超时了qwq")
|
||||
if response.status_code != 200:
|
||||
return Failure("无法下载图片,可能存在网络问题需要排查")
|
||||
return Success(response.content)
|
||||
|
||||
|
||||
def bytes_to_pil(raw_data: bytes | BytesIO) -> Result[PIL.Image.Image, str]:
|
||||
try:
|
||||
if not isinstance(raw_data, BytesIO):
|
||||
img_pil = PIL.Image.open(BytesIO(raw_data))
|
||||
else:
|
||||
img_pil = PIL.Image.open(raw_data)
|
||||
img_pil.verify()
|
||||
if not isinstance(raw_data, BytesIO):
|
||||
img = PIL.Image.open(BytesIO(raw_data))
|
||||
else:
|
||||
raw_data.seek(0)
|
||||
img = PIL.Image.open(raw_data)
|
||||
return Success(img)
|
||||
except UnidentifiedImageError:
|
||||
return Failure("图像无法读取,可能是格式不支持orz")
|
||||
except IOError:
|
||||
return Failure("图像无法读取,可能是网络存在问题orz")
|
||||
|
||||
|
||||
async def unimsg_img_to_pil(image: Image) -> Result[PIL.Image.Image, str]:
|
||||
if image.url is not None:
|
||||
raw_result = await download_image_bytes(image.url)
|
||||
elif image.raw is not None:
|
||||
raw_result = Success(image.raw)
|
||||
else:
|
||||
return Failure("由于一些内部问题,下载图片失败了orz")
|
||||
|
||||
return raw_result.bind(bytes_to_pil)
|
||||
|
||||
|
||||
async def extract_image_from_qq_message(
|
||||
msg: OnebotV11Message,
|
||||
evt: OnebotV11MessageEvent,
|
||||
bot: OnebotV11Bot,
|
||||
allow_reply: bool = True,
|
||||
) -> Result[PIL.Image.Image, str]:
|
||||
if allow_reply and (reply := evt.reply) is not None:
|
||||
return await extract_image_from_qq_message(
|
||||
reply.message,
|
||||
evt,
|
||||
bot,
|
||||
False,
|
||||
)
|
||||
for seg in msg:
|
||||
if seg.type == "reply" and allow_reply:
|
||||
msgid = seg.data.get("id")
|
||||
if msgid is None:
|
||||
return Failure("消息可能太久远,无法读取到消息原文")
|
||||
try:
|
||||
msg2 = await bot.get_msg(message_id=msgid)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取消息内容时出错:{e}")
|
||||
return Failure("消息可能太久远,无法读取到消息原文")
|
||||
msg2_data = msg2.get("message")
|
||||
if msg2_data is None:
|
||||
return Failure("消息可能太久远,无法读取到消息原文")
|
||||
logger.debug("发现消息引用,递归一层")
|
||||
return await extract_image_from_qq_message(
|
||||
msg=OnebotV11Message(msg2_data),
|
||||
evt=evt,
|
||||
bot=bot,
|
||||
allow_reply=False,
|
||||
)
|
||||
if seg.type == "image":
|
||||
url = seg.data.get("url")
|
||||
if url is None:
|
||||
return Failure("无法下载图片,可能有一些网络问题")
|
||||
data = await download_image_bytes(url)
|
||||
return data.bind(bytes_to_pil)
|
||||
|
||||
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
||||
|
||||
|
||||
async def extract_image_from_message(
|
||||
msg: Message,
|
||||
evt: Event,
|
||||
bot: Bot,
|
||||
allow_reply: bool = True,
|
||||
) -> Result[PIL.Image.Image, str]:
|
||||
if (
|
||||
isinstance(bot, OnebotV11Bot)
|
||||
and isinstance(msg, OnebotV11Message)
|
||||
and isinstance(evt, OnebotV11MessageEvent)
|
||||
):
|
||||
# 看起来 UniMessage 在这方面能力似乎不足,因此用 QQ 的
|
||||
logger.debug('获取图片的路径 Fallback 到 QQ 模块')
|
||||
return await extract_image_from_qq_message(msg, evt, bot, allow_reply)
|
||||
|
||||
for seg in UniMessage.of(msg, bot):
|
||||
logger.info(seg)
|
||||
if isinstance(seg, Image):
|
||||
return await unimsg_img_to_pil(seg)
|
||||
elif isinstance(seg, Reply) and allow_reply:
|
||||
msg2 = seg.msg
|
||||
logger.debug(f"深入搜索引用的消息:{msg2}")
|
||||
if msg2 is None or isinstance(msg2, str):
|
||||
continue
|
||||
return await extract_image_from_message(msg2, evt, bot, False)
|
||||
elif isinstance(seg, RefNode) and allow_reply:
|
||||
if isinstance(bot, DiscordBot):
|
||||
return Failure("暂时不支持在 Discord 中通过引用的方式获取图片")
|
||||
else:
|
||||
return Failure("暂时不支持在这里中通过引用的方式获取图片")
|
||||
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
||||
|
||||
|
||||
async def _ext_img(
|
||||
evt: Event,
|
||||
bot: Bot,
|
||||
matcher: Matcher,
|
||||
) -> PIL.Image.Image | None:
|
||||
match await extract_image_from_message(evt.get_message(), evt, bot):
|
||||
case Success(img):
|
||||
return img
|
||||
case Failure(err):
|
||||
# raise BotExceptionMessage(err)
|
||||
await matcher.send(await UniMessage().text(err).export())
|
||||
return None
|
||||
assert False
|
||||
|
||||
|
||||
PIL_Image = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)]
|
||||
34
konabot/common/nb/is_admin.py
Normal file
@ -0,0 +1,34 @@
|
||||
from nonebot import get_plugin_config
|
||||
import nonebot
|
||||
import nonebot.adapters
|
||||
import nonebot.adapters.console
|
||||
import nonebot.adapters.discord
|
||||
import nonebot.adapters.onebot
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class IsAdminConfig(BaseModel):
|
||||
admin_qq_group: list[int] = []
|
||||
admin_qq_account: list[int] = []
|
||||
admin_discord_channel: list[int] = []
|
||||
admin_discord_account: list[int] = []
|
||||
|
||||
cfg = get_plugin_config(IsAdminConfig)
|
||||
|
||||
|
||||
def is_admin(event: nonebot.adapters.Event):
|
||||
if isinstance(event, nonebot.adapters.onebot.v11.MessageEvent):
|
||||
if event.user_id in cfg.admin_qq_account:
|
||||
return True
|
||||
if isinstance(event, nonebot.adapters.onebot.v11.GroupMessageEvent):
|
||||
if event.group_id in cfg.admin_qq_group:
|
||||
return True
|
||||
if isinstance(event, nonebot.adapters.discord.event.MessageEvent):
|
||||
if event.channel_id in cfg.admin_discord_channel:
|
||||
return True
|
||||
if event.user_id in cfg.admin_discord_account:
|
||||
return True
|
||||
if isinstance(event, nonebot.adapters.console.event.Event):
|
||||
return True
|
||||
|
||||
return False
|
||||
16
konabot/common/nb/match_keyword.py
Normal file
@ -0,0 +1,16 @@
|
||||
import re
|
||||
|
||||
from nonebot_plugin_alconna import Text, UniMsg
|
||||
|
||||
|
||||
def match_keyword(*patterns: str | re.Pattern):
|
||||
async def _matcher(msg: UniMsg):
|
||||
text = msg.get(Text).extract_plain_text().strip()
|
||||
for pattern in patterns:
|
||||
if isinstance(pattern, str) and text == pattern:
|
||||
return True
|
||||
if isinstance(pattern, re.Pattern) and re.match(pattern, text):
|
||||
return True
|
||||
return False
|
||||
|
||||
return _matcher
|
||||
13
konabot/common/nb/reply_image.py
Normal file
@ -0,0 +1,13 @@
|
||||
from io import BytesIO
|
||||
|
||||
import PIL
|
||||
import PIL.Image
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
|
||||
async def reply_image(matcher: type[Matcher], bot: Bot, img: PIL.Image.Image):
|
||||
data = BytesIO()
|
||||
img.save(data, "PNG")
|
||||
await matcher.send(await UniMessage().image(raw=data).export(bot))
|
||||
12
konabot/common/path.py
Normal file
@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS_PATH = Path(__file__).resolve().parent.parent.parent / "assets"
|
||||
FONTS_PATH = ASSETS_PATH / "fonts"
|
||||
|
||||
SRC_PATH = Path(__file__).resolve().parent.parent
|
||||
|
||||
DOCS_PATH = SRC_PATH / "docs"
|
||||
DOCS_PATH_MAN1 = DOCS_PATH / "user"
|
||||
DOCS_PATH_MAN3 = DOCS_PATH / "lib"
|
||||
DOCS_PATH_MAN7 = DOCS_PATH / "concepts"
|
||||
DOCS_PATH_MAN8 = DOCS_PATH / "sys"
|
||||
40
konabot/docs/README.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 此方 Bot 的文档系统
|
||||
|
||||
此方 Bot 使用类 Linux 的 `man` 指令来管理文档。文档一般建议使用纯文本书写,带有相对良好的格式。
|
||||
|
||||
## 文件夹摆放规则
|
||||
|
||||
`docs` 目录下,有若干文档可以拿来阅读和输出。每个子文件夹里,文档文件使用名字不含空格的 txt 文件书写,其他后缀名的文件将会被忽略。所以,如果你希望有些文件只在代码库中可阅读,你可以使用 `.md` 格式。
|
||||
|
||||
### 1 - user
|
||||
|
||||
`docs/user` 目录下的文档是直接会给用户进行检索的文档,在直接使用 `man` 指令时,会搜索该文件夹的全部文件,以知晓所有有文档的指令。
|
||||
|
||||
### 3 - lib
|
||||
|
||||
`docs/lib` 目录下的文档主要给该项目的维护者进行阅读和使用,讲述的是本项目内置的一些函数的功能讲解(一般以便利为主要目的)以及一些项目安排上的要求。一般不会列举,除非用户指定要求列举该范围。
|
||||
|
||||
### 7 - concepts
|
||||
|
||||
`docs/concepts` 用来摆放任何的概念。任何的。一般不会列举,除非用户指定要求列举该范围。
|
||||
|
||||
### 8 - sys
|
||||
|
||||
`docs/sys` 用于摆放仅 MTTU 群可以使用的文档集合。在 MTTU 群内,该目录下的文档也会被索引,否则文档将不可阅读。
|
||||
|
||||
## 书写规范
|
||||
|
||||
无特殊要求,因为当用户进行 `man` 的时候,会将文档内的内容原封不动地展示出来。但是,你仍然可以模仿 Linux 下的 `man` 指令的格式进行书写。
|
||||
|
||||
```
|
||||
指令介绍
|
||||
man - 用于展示此方 BOT 使用手册的指令
|
||||
|
||||
格式
|
||||
man [文档类型] <指令>
|
||||
|
||||
示例
|
||||
`man` 查看所有有文档的指令清单
|
||||
`man 喵` 查看指令「喵」的使用说明
|
||||
……
|
||||
```
|
||||
0
konabot/docs/concepts/占位.md
Normal file
45
konabot/docs/lib/is_admin.txt
Normal file
@ -0,0 +1,45 @@
|
||||
指令介绍
|
||||
is_admin - 用于判断当前事件是否来自管理员的内部权限校验函数
|
||||
|
||||
格式
|
||||
from konabot.common.nb.is_admin import is_admin
|
||||
from nonebot import on
|
||||
from nonebot.adapters import Event
|
||||
from loguru import logger
|
||||
|
||||
@on().handle()
|
||||
async def _(event: Event):
|
||||
if is_admin(event):
|
||||
logger.info("管理员发送了消息")
|
||||
|
||||
说明
|
||||
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
||||
|
||||
支持的适配器与判定逻辑:
|
||||
• OneBot V11(QQ)
|
||||
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
||||
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
||||
|
||||
• Discord
|
||||
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
||||
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
||||
|
||||
• Console(控制台)
|
||||
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
||||
|
||||
配置项(位于插件配置中)
|
||||
ADMIN_QQ_GROUP: list[int]
|
||||
允许的管理员 QQ 群 ID 列表
|
||||
|
||||
ADMIN_QQ_ACCOUNT: list[int]
|
||||
允许的管理员 QQ 账号 ID 列表
|
||||
|
||||
ADMIN_DISCORD_CHANNEL: list[int]
|
||||
允许的管理员 Discord 频道 ID 列表
|
||||
|
||||
ADMIN_DISCORD_ACCOUNT: list[int]
|
||||
允许的管理员 Discord 用户 ID 列表
|
||||
|
||||
注意事项
|
||||
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
||||
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
||||
0
konabot/docs/lib/占位.md
Normal file
1
konabot/docs/sys/out.txt
Normal file
@ -0,0 +1 @@
|
||||
MAN what can I say!
|
||||
20
konabot/docs/user/man.txt
Normal file
@ -0,0 +1,20 @@
|
||||
指令介绍
|
||||
man - 用于展示此方 BOT 使用手册的指令
|
||||
|
||||
格式
|
||||
man 文档类型
|
||||
man [文档类型] <指令>
|
||||
|
||||
示例
|
||||
`man` 查看所有有文档的指令清单
|
||||
`man 3` 列举所有可读文档的库函数清单
|
||||
`man 喵` 查看指令「喵」的使用说明
|
||||
`man 8 out` 查看管理员指令「out」的使用说明
|
||||
|
||||
文档类型
|
||||
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为这些种类:
|
||||
|
||||
- 1 用户态指令,用于日常使用的指令
|
||||
- 3 库函数指令,用于 Bot 开发用的函数查询
|
||||
- 7 概念指令,用于概念解释
|
||||
- 8 系统指令,仅管理员可用
|
||||
21
konabot/docs/user/openssl.txt
Normal file
@ -0,0 +1,21 @@
|
||||
指令介绍
|
||||
openssl - 用于生成指定长度的加密安全随机数据
|
||||
|
||||
格式
|
||||
openssl rand <模式> <字节数>
|
||||
|
||||
示例
|
||||
`openssl rand -hex 16` 生成 16 字节的十六进制随机数
|
||||
`openssl rand -base64 32` 生成 32 字节并以 Base64 编码输出的随机数据
|
||||
|
||||
说明
|
||||
该指令使用 Python 的 secrets 模块生成加密安全的随机字节,并支持以十六进制(-hex)或 Base64(-base64)格式输出。
|
||||
|
||||
参数说明
|
||||
模式(mode)
|
||||
- -hex :以十六进制字符串形式输出随机数据
|
||||
- -base64 :以 Base64 编码字符串形式输出随机数据
|
||||
|
||||
字节数(num)
|
||||
- 必须为正整数
|
||||
- 最大支持 256 字节
|
||||
41
konabot/docs/user/ytpgif.txt
Normal file
@ -0,0 +1,41 @@
|
||||
指令介绍
|
||||
ytpgif - 生成来回镜像翻转的仿 YTPMV 动图
|
||||
|
||||
格式
|
||||
ytpgif [倍速]
|
||||
|
||||
示例
|
||||
`ytpgif`
|
||||
使用默认倍速(1.0)处理你发送或回复的图片,生成镜像动图。
|
||||
|
||||
`ytpgif 2.5`
|
||||
以 2.5 倍速处理图片,生成更快节奏的镜像动图。
|
||||
|
||||
回复一张图片并发送 `ytpgif 0.5`
|
||||
以慢速(0.5 倍)生成镜像动图。
|
||||
|
||||
参数说明
|
||||
倍速(可选)
|
||||
- 类型:浮点数
|
||||
- 默认值:1.0
|
||||
- 有效范围:0.1 ~ 20.0
|
||||
- 作用:
|
||||
• 对于静态图:控制镜像切换的快慢(值越大,切换越快)。
|
||||
• 对于动图:控制截取原始动图正向和反向片段的时长(值越大,截取的片段越长)。
|
||||
|
||||
使用方式
|
||||
发送指令前,请确保:
|
||||
- 直接在消息中附带一张图片,或
|
||||
- 回复一条包含图片的消息后再发送指令。
|
||||
|
||||
插件会自动:
|
||||
- 下载并识别图片(支持静态图和 GIF 动图)
|
||||
- 自动缩放至最大边长不超过 256 像素(保持宽高比)
|
||||
- 静态图 → 生成“原图↔镜像”循环动图
|
||||
- 动图 → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图
|
||||
- 保留透明通道(如原图含透明),否则转为 RGB 避免颜色异常
|
||||
|
||||
注意事项
|
||||
- 图片过大、格式损坏或网络问题可能导致处理失败。
|
||||
- 动图帧数过多或单帧过短可能无法生成有效输出。
|
||||
- 输出 GIF 最大单段帧数限制为 500 帧,以防资源耗尽。
|
||||
2
konabot/docs/user/喵.txt
Normal file
@ -0,0 +1,2 @@
|
||||
指令介绍
|
||||
喵 - 你发喵,此方就会回复喵
|
||||
7
konabot/docs/user/摇数字.txt
Normal file
@ -0,0 +1,7 @@
|
||||
指令介绍
|
||||
摇数字 - 生成一个随机数字并发送
|
||||
|
||||
示例
|
||||
`摇数字` 随机生成一个 1-6 的数字
|
||||
|
||||
该指令不接受任何参数,直接调用即可。
|
||||
22
konabot/docs/user/摇骰子.txt
Normal file
@ -0,0 +1,22 @@
|
||||
指令介绍
|
||||
摇骰子 - 用于生成随机数并以骰子图像形式展示的指令
|
||||
|
||||
格式
|
||||
摇骰子 [最小值] [最大值]
|
||||
|
||||
示例
|
||||
`摇骰子` 随机生成一个 1-6 的数字,并显示对应的骰子图像
|
||||
`摇骰子 10` 生成 1 到 10 之间的随机整数
|
||||
`摇骰子 0.5` 生成 0 到 0.5 之间的随机小数
|
||||
`摇骰子 -5 5` 生成 -5 到 5 之间的随机数
|
||||
|
||||
说明
|
||||
该指令支持以下几种调用方式:
|
||||
- 不带参数:使用默认范围生成随机数
|
||||
- 仅指定一个参数 f1:
|
||||
- 若 f1 > 1,则生成 [1, f1] 范围内的随机数
|
||||
- 若 0 < f1 ≤ 1,则生成 [0, f1] 范围内的随机数
|
||||
- 若 f1 ≤ 0,则生成 [f1, 0] 范围内的随机数
|
||||
- 指定两个参数 f1 和 f2:生成 [f1, f2] 范围内的随机数(顺序无关,内部会自动处理大小)
|
||||
|
||||
返回结果将以骰子样式的图像形式展示生成的随机数值。
|
||||
13
konabot/docs/user/雷达回波.txt
Normal file
@ -0,0 +1,13 @@
|
||||
指令介绍
|
||||
雷达回波 - 用于获取指定地区的天气雷达回波图像
|
||||
|
||||
格式
|
||||
雷达回波 <地区>
|
||||
|
||||
示例
|
||||
`雷达回波 华南` 获取华南地区的天气雷达回波图
|
||||
`雷达回波 全国` 获取全国的天气雷达回波图
|
||||
|
||||
说明
|
||||
该指令通过查询中国气象局 https://www.nmc.cn/publish/radar/chinaall.html ,获取指定地区的实时天气雷达回波图像。
|
||||
支持的地区有:全国 华北 东北 华东 华中 华南 西南 西北。
|
||||
45
konabot/plugins/errman.py
Normal file
@ -0,0 +1,45 @@
|
||||
from typing import Any
|
||||
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.message import run_postprocessor
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from returns.primitives.exceptions import UnwrapFailedError
|
||||
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
|
||||
|
||||
@run_postprocessor
|
||||
async def _(bot: Bot, matcher: Matcher, exc: BotExceptionMessage | AssertionError | UnwrapFailedError):
|
||||
if isinstance(exc, BotExceptionMessage):
|
||||
msg = exc.msg
|
||||
await matcher.send(await msg.export(bot))
|
||||
if isinstance(exc, AssertionError):
|
||||
if exc.args:
|
||||
err_msg = exc.args[0]
|
||||
|
||||
err_msg_res: UniMessage
|
||||
if isinstance(err_msg, str):
|
||||
err_msg_res = UniMessage().text(err_msg)
|
||||
elif isinstance(err_msg, UniMessage):
|
||||
err_msg_res = err_msg
|
||||
else:
|
||||
return
|
||||
|
||||
await matcher.send(await err_msg_res.export(bot))
|
||||
if isinstance(exc, UnwrapFailedError):
|
||||
obj = exc.halted_container
|
||||
try:
|
||||
failure: Any = obj.failure()
|
||||
|
||||
err_msg_res: UniMessage
|
||||
if isinstance(failure, str):
|
||||
err_msg_res = UniMessage().text(failure)
|
||||
elif isinstance(failure, UniMessage):
|
||||
err_msg_res = failure
|
||||
else:
|
||||
return
|
||||
|
||||
await matcher.send(await err_msg_res.export(bot))
|
||||
except:
|
||||
pass
|
||||
13
konabot/plugins/image_process/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
from nonebot import on_message
|
||||
from nonebot.adapters import Bot
|
||||
|
||||
from konabot.common.nb.extract_image import PIL_Image
|
||||
from konabot.common.nb.match_keyword import match_keyword
|
||||
from konabot.common.nb.reply_image import reply_image
|
||||
|
||||
cmd_black_white = on_message(rule=match_keyword("黑白"))
|
||||
|
||||
|
||||
@cmd_black_white.handle()
|
||||
async def _(img: PIL_Image, bot: Bot):
|
||||
await reply_image(cmd_black_white, bot, img.convert("LA"))
|
||||
104
konabot/plugins/man/__init__.py
Normal file
@ -0,0 +1,104 @@
|
||||
from curses.ascii import isdigit
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
import nonebot.adapters
|
||||
import nonebot.adapters.discord
|
||||
import nonebot.rule
|
||||
from nonebot import on_command
|
||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||
|
||||
from konabot.common.nb.is_admin import is_admin
|
||||
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
||||
|
||||
def search_man(section: int) -> dict[tuple[int, str], Path]:
|
||||
base_path = {
|
||||
1: DOCS_PATH_MAN1,
|
||||
3: DOCS_PATH_MAN3,
|
||||
7: DOCS_PATH_MAN7,
|
||||
8: DOCS_PATH_MAN8,
|
||||
}.get(section, DOCS_PATH_MAN1)
|
||||
|
||||
res: dict[tuple[int, str], Path] = {}
|
||||
for fp in base_path.iterdir():
|
||||
if fp.suffix != '.txt':
|
||||
continue
|
||||
name = fp.name.lower().removesuffix('.txt')
|
||||
res[(section, name)] = fp
|
||||
return res
|
||||
|
||||
|
||||
man = on_alconna(Alconna(
|
||||
'man',
|
||||
Args['section', int | None],
|
||||
Args['doc', str | None],
|
||||
))
|
||||
|
||||
@man.handle()
|
||||
async def _(
|
||||
section: int | None,
|
||||
doc: str | None,
|
||||
event: nonebot.adapters.Event,
|
||||
):
|
||||
if doc is not None and section is None and all(isdigit(c) for c in doc):
|
||||
section = int(doc)
|
||||
doc = None
|
||||
|
||||
if section is not None and section not in {1, 3, 7, 8}:
|
||||
await man.send(
|
||||
UniMessage().text(f"你所指定的文档类型 {section} 不在可用范围内")
|
||||
)
|
||||
return
|
||||
|
||||
if doc is None:
|
||||
# 检索模式
|
||||
if section is None:
|
||||
section_set = {1}
|
||||
else:
|
||||
section_set = {section}
|
||||
if 1 in section_set and is_admin(event):
|
||||
section_set.add(8)
|
||||
mans: list[str] = []
|
||||
for section in section_set:
|
||||
mans += [f"{n}({s})" for s, n in search_man(section).keys()]
|
||||
mans.sort()
|
||||
|
||||
await man.send(UniMessage().text(
|
||||
(
|
||||
"★此方 BOT 使用帮助★\n"
|
||||
"使用 man <指令名> 查询某个指令的名字\n\n"
|
||||
"可供查询的指令清单:"
|
||||
)
|
||||
+ ", ".join(mans)
|
||||
+ "\n\n例如,使用 man man 来查询 man 指令的使用方法"
|
||||
))
|
||||
else:
|
||||
# 查阅模式
|
||||
if section is None:
|
||||
section_set = {1}
|
||||
else:
|
||||
section_set = {section}
|
||||
if 1 in section_set and is_admin(event):
|
||||
section_set.add(8)
|
||||
if 8 in section_set and not is_admin(event):
|
||||
await man.send(UniMessage().text("你没有查看该指令类型的权限"))
|
||||
return
|
||||
mans_dict: dict[tuple[int, str], Path] = {}
|
||||
for section in section_set:
|
||||
mans_dict: dict[tuple[int, str], Path] = {**mans_dict, **search_man(section)}
|
||||
mans_dict_2 = {key[1]: val for key, val in mans_dict.items()}
|
||||
mans_fp = mans_dict_2.get(doc.lower())
|
||||
if mans_fp is None:
|
||||
await man.send(UniMessage().text("你所检索的指令不存在"))
|
||||
return
|
||||
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
||||
if isinstance(event, nonebot.adapters.discord.event.MessageEvent):
|
||||
mans_msg = f'```\n{mans_msg}\n```'
|
||||
await man.send(UniMessage().text(mans_msg))
|
||||
|
||||
|
||||
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
||||
|
||||
@help_deprecated.handle()
|
||||
async def _():
|
||||
await help_deprecated.send('你可以使用 man 指令来查询此方 BOT 的帮助')
|
||||
@ -1,14 +1,26 @@
|
||||
from io import BytesIO
|
||||
from nonebot_plugin_alconna import Alconna, Args, Field, MultiVar, UniMessage, on_alconna
|
||||
from typing import Iterable, cast
|
||||
|
||||
from konabot.plugins.memepack.drawing.geimao import draw_geimao
|
||||
from nonebot import on_message
|
||||
from nonebot_plugin_alconna import (Alconna, Args, Field, MultiVar, Text,
|
||||
UniMessage, UniMsg, on_alconna)
|
||||
|
||||
from konabot.common.nb.extract_image import extract_image_from_message
|
||||
from konabot.plugins.memepack.drawing.display import draw_cao_display
|
||||
from konabot.plugins.memepack.drawing.saying import (draw_cute_ten,
|
||||
draw_geimao, draw_mnk,
|
||||
draw_pt, draw_suan)
|
||||
|
||||
from nonebot.adapters import Bot, Event
|
||||
|
||||
from returns.result import Success, Failure
|
||||
|
||||
geimao = on_alconna(Alconna(
|
||||
"给猫说",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "你没有写给猫说了什么"
|
||||
)]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False)
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"给猫哈"})
|
||||
|
||||
@geimao.handle()
|
||||
async def _(saying: list[str]):
|
||||
@ -17,3 +29,113 @@ async def _(saying: list[str]):
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await geimao.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
pt = on_alconna(Alconna(
|
||||
"pt说",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "你没有写小帕说了什么"
|
||||
)]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"小帕说"})
|
||||
|
||||
@pt.handle()
|
||||
async def _(saying: list[str]):
|
||||
img = await draw_pt("\n".join(saying))
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await pt.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
mnk = on_alconna(Alconna(
|
||||
"re:小?黑白子?说",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "你没有写黑白子说了什么"
|
||||
)]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"mnk说"})
|
||||
|
||||
@mnk.handle()
|
||||
async def _(saying: list[str]):
|
||||
img = await draw_mnk("\n".join(saying))
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await mnk.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
suan = on_alconna(Alconna(
|
||||
"小蒜说",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "你没有写小蒜说了什么"
|
||||
)]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases=set())
|
||||
|
||||
@suan.handle()
|
||||
async def _(saying: list[str]):
|
||||
img = await draw_suan("\n".join(saying))
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await suan.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
dsuan = on_alconna(Alconna(
|
||||
"大蒜说",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "你没有写大蒜说了什么"
|
||||
)]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases=set())
|
||||
|
||||
@dsuan.handle()
|
||||
async def _(saying: list[str]):
|
||||
img = await draw_suan("\n".join(saying), True)
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await dsuan.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
cutecat = on_alconna(Alconna(
|
||||
"乖猫说",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "你没有写十猫说了什么"
|
||||
)]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"十猫说"})
|
||||
|
||||
@cutecat.handle()
|
||||
async def _(saying: list[str]):
|
||||
img = await draw_cute_ten("\n".join(saying))
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await cutecat.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
cao_display_cmd = on_message()
|
||||
|
||||
@cao_display_cmd.handle()
|
||||
async def _(msg: UniMsg, evt: Event, bot: Bot):
|
||||
flag = False
|
||||
for text in cast(Iterable[Text], msg.get(Text)):
|
||||
if text.text.strip() == "小槽展示":
|
||||
flag = True
|
||||
elif text.text.strip() == '':
|
||||
continue
|
||||
else:
|
||||
return
|
||||
if not flag:
|
||||
return
|
||||
match await extract_image_from_message(evt.get_message(), evt, bot):
|
||||
case Success(img):
|
||||
img_handled = await draw_cao_display(img)
|
||||
img_bytes = BytesIO()
|
||||
img_handled.save(img_bytes, format="PNG")
|
||||
await cao_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
|
||||
case Failure(err):
|
||||
await cao_display_cmd.send(
|
||||
await UniMessage()
|
||||
.at(user_id=evt.get_user_id())
|
||||
.text(' ')
|
||||
.text(err)
|
||||
.export()
|
||||
)
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
from imagetext_py import FontDB
|
||||
from imagetext_py import EmojiOptions, FontDB
|
||||
|
||||
from .path import assets
|
||||
from konabot.common.path import FONTS_PATH
|
||||
|
||||
FontDB.LoadFromDir(str(FONTS_PATH))
|
||||
|
||||
FontDB.SetDefaultEmojiOptions(EmojiOptions(
|
||||
parse_shortcodes=False,
|
||||
))
|
||||
|
||||
FontDB.LoadFromDir(str(assets))
|
||||
HARMONYOS_SANS_SC_BLACK = FontDB.Query("HarmonyOS_Sans_SC_Black")
|
||||
HARMONYOS_SANS_SC_REGULAR = FontDB.Query("HarmonyOS_Sans_SC_Regular")
|
||||
LXGWWENKAI_REGULAR = FontDB.Query("LXGWWenKai-Regular")
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
assets = Path(__file__).parent.parent.parent / "assets"
|
||||
45
konabot/plugins/memepack/drawing/display.py
Normal file
@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import PIL.Image
|
||||
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
|
||||
cao_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "caoimg1.png")
|
||||
CAO_QUAD_POINTS = np.float32(cast(Any, [
|
||||
[392, 540],
|
||||
[577, 557],
|
||||
[567, 707],
|
||||
[381, 687],
|
||||
]))
|
||||
|
||||
def _draw_cao_display(image: PIL.Image.Image):
|
||||
src = np.array(image.convert("RGB"))
|
||||
h, w = src.shape[:2]
|
||||
src_points = np.float32(cast(Any, [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[w, h],
|
||||
[0, h]
|
||||
]))
|
||||
dst_points = CAO_QUAD_POINTS
|
||||
M = cv2.getPerspectiveTransform(cast(Any, src_points), cast(Any, dst_points))
|
||||
output_size = cao_image.size
|
||||
output_w, output_h = output_size
|
||||
warped = cv2.warpPerspective(
|
||||
src,
|
||||
M,
|
||||
(output_w, output_h),
|
||||
flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT,
|
||||
borderValue=(0, 0, 0)
|
||||
)
|
||||
result = PIL.Image.fromarray(warped, 'RGB').convert('RGBA')
|
||||
result = PIL.Image.alpha_composite(result, cao_image)
|
||||
return result
|
||||
|
||||
|
||||
async def draw_cao_display(image: PIL.Image.Image):
|
||||
return await asyncio.to_thread(_draw_cao_display, image)
|
||||
@ -1,28 +0,0 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import imagetext_py
|
||||
import PIL.Image
|
||||
|
||||
from .base.fonts import HARMONYOS_SANS_SC_BLACK
|
||||
from .base.path import assets
|
||||
|
||||
geimao_image = PIL.Image.open(assets / "geimao.jpg").convert("RGBA")
|
||||
|
||||
|
||||
def _draw_geimao(saying: str):
|
||||
img = geimao_image.copy()
|
||||
with imagetext_py.Writer(img) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, 960, 50, 00.5, 0, 1920, 240, HARMONYOS_SANS_SC_BLACK,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
0.8,
|
||||
imagetext_py.TextAlign.Center,
|
||||
cast(Any, 30.0),
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("FFFFFFFF")),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
async def draw_geimao(saying: str):
|
||||
return await asyncio.to_thread(_draw_geimao, saying)
|
||||
108
konabot/plugins/memepack/drawing/saying.py
Normal file
@ -0,0 +1,108 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import imagetext_py
|
||||
import PIL.Image
|
||||
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
|
||||
from .base.fonts import HARMONYOS_SANS_SC_BLACK, HARMONYOS_SANS_SC_REGULAR, LXGWWENKAI_REGULAR
|
||||
|
||||
geimao_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "geimao.jpg").convert("RGBA")
|
||||
pt_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "ptsay.png").convert("RGBA")
|
||||
mnk_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "mnksay.jpg").convert("RGBA")
|
||||
dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert("RGBA")
|
||||
suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert("RGBA")
|
||||
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert("RGBA")
|
||||
|
||||
|
||||
def _draw_geimao(saying: str):
|
||||
img = geimao_image.copy()
|
||||
with imagetext_py.Writer(img) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, 960, 50, 0.5, 0, 1920, 240, HARMONYOS_SANS_SC_BLACK,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
0.8,
|
||||
imagetext_py.TextAlign.Center,
|
||||
cast(Any, 30.0),
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("FFFFFFFF")),
|
||||
draw_emojis=True,
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
async def draw_geimao(saying: str):
|
||||
return await asyncio.to_thread(_draw_geimao, saying)
|
||||
|
||||
|
||||
def _draw_pt(saying: str):
|
||||
img = pt_image.copy()
|
||||
with imagetext_py.Writer(img) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, 259, 278, 0.5, 0.5, 360, 48, HARMONYOS_SANS_SC_REGULAR,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
1.0,
|
||||
imagetext_py.TextAlign.Center,
|
||||
draw_emojis=True,
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
async def draw_pt(saying: str):
|
||||
return await asyncio.to_thread(_draw_pt, saying)
|
||||
|
||||
|
||||
def _draw_mnk(saying: str):
|
||||
img = mnk_image.copy()
|
||||
with imagetext_py.Writer(img) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, 540, 25, 0.5, 0, 1080, 120, HARMONYOS_SANS_SC_BLACK,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
0.8,
|
||||
imagetext_py.TextAlign.Center,
|
||||
cast(Any, 15.0),
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("FFFFFFFF")),
|
||||
draw_emojis=True,
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
async def draw_mnk(saying: str):
|
||||
return await asyncio.to_thread(_draw_mnk, saying)
|
||||
|
||||
|
||||
def _draw_suan(saying: str, dasuan: bool = False):
|
||||
if dasuan:
|
||||
img = dasuan_image.copy()
|
||||
else:
|
||||
img = suan_image.copy()
|
||||
with imagetext_py.Writer(img) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, 1020, 290, 0.5, 0.5, 400, 48, LXGWWENKAI_REGULAR,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
1.0,
|
||||
imagetext_py.TextAlign.Center,
|
||||
draw_emojis=True,
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
async def draw_suan(saying: str, dasuan: bool = False):
|
||||
return await asyncio.to_thread(_draw_suan, saying, dasuan)
|
||||
|
||||
|
||||
def _draw_cute_ten(saying: str):
|
||||
img = cute_ten_image.copy()
|
||||
with imagetext_py.Writer(img) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, 390, 479, 0.5, 0.5, 760, 96, LXGWWENKAI_REGULAR,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
1.0,
|
||||
imagetext_py.TextAlign.Center,
|
||||
draw_emojis=True,
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
async def draw_cute_ten(saying: str):
|
||||
return await asyncio.to_thread(_draw_cute_ten, saying)
|
||||
@ -1,19 +1,47 @@
|
||||
from typing import Optional, Union
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import Alconna, UniMessage, on_alconna
|
||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||
|
||||
from konabot.plugins.roll_dice.roll_dice import roll_dice
|
||||
from konabot.plugins.roll_dice.roll_dice import generate_dice_image
|
||||
from konabot.plugins.roll_dice.roll_number import get_random_number, get_random_number_string, roll_number
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"摇骰子"
|
||||
"摇数字"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
await evt.send(await UniMessage().text("```\n" + roll_dice() + "\n```").export())
|
||||
await evt.send(await UniMessage().text("```\n" + roll_number() + "\n```").export())
|
||||
elif isinstance(event, ConsoleMessageEvent):
|
||||
await evt.send(await UniMessage().text(roll_dice()).export())
|
||||
await evt.send(await UniMessage().text(roll_number()).export())
|
||||
else:
|
||||
await evt.send(await UniMessage().text(roll_dice(wide=True)).export())
|
||||
await evt.send(await UniMessage().text(roll_number(wide=True)).export())
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"摇骰子",
|
||||
Args["f1?", str]["f2?", str]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, f1: Optional[str] = None, f2: Optional[str] = None):
|
||||
# if isinstance(event, DiscordMessageEvent):
|
||||
# await evt.send(await UniMessage().text("```\n" + roll_dice() + "\n```").export())
|
||||
# elif isinstance(event, ConsoleMessageEvent):
|
||||
number = ""
|
||||
if(f1 is not None and f2 is not None):
|
||||
number = get_random_number_string(f1, f2)
|
||||
elif f1 is not None:
|
||||
if(float(f1) > 1):
|
||||
number = get_random_number_string("1", f1)
|
||||
elif (float(f1) > 0):
|
||||
number = get_random_number_string("0", f1)
|
||||
else:
|
||||
number = get_random_number_string(f1, "0")
|
||||
else:
|
||||
number = get_random_number_string()
|
||||
await evt.send(await UniMessage().image(raw=await generate_dice_image(number)).export())
|
||||
# else:
|
||||
# await evt.send(await UniMessage().text(roll_dice(wide=True)).export())
|
||||
|
||||
3
konabot/plugins/roll_dice/base/path.py
Normal file
@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS = Path(__file__).parent.parent / "assets"
|
||||
@ -1,54 +1,400 @@
|
||||
number_arts = {
|
||||
1: ''' _
|
||||
/ |
|
||||
| |
|
||||
| |
|
||||
|_|
|
||||
from io import BytesIO
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from konabot.common.path import ASSETS_PATH, FONTS_PATH
|
||||
|
||||
|
||||
def text_to_transparent_image(text, font_size=40, padding=0, text_color=(0, 0, 0)):
|
||||
"""
|
||||
将文本转换为带透明背景的图像,图像大小刚好包含文本
|
||||
"""
|
||||
# 创建临时图像来计算文本尺寸
|
||||
temp_image = Image.new('RGB', (1, 1), (255, 255, 255))
|
||||
temp_draw = ImageDraw.Draw(temp_image)
|
||||
|
||||
''',
|
||||
2: ''' ____
|
||||
|___ \\
|
||||
__) |
|
||||
/ __/
|
||||
|_____|
|
||||
''',
|
||||
3: ''' _____
|
||||
|___ /
|
||||
|_ \\
|
||||
___) |
|
||||
|____/
|
||||
''',
|
||||
4: ''' _ _
|
||||
| || |
|
||||
| || |_
|
||||
|__ _|
|
||||
|_|
|
||||
''',
|
||||
5: ''' ____
|
||||
| ___|
|
||||
|___ \\
|
||||
___) |
|
||||
|____/
|
||||
''',
|
||||
6: ''' __
|
||||
/ /_
|
||||
| '_ \\
|
||||
| (_) |
|
||||
\\___/
|
||||
'''
|
||||
}
|
||||
font = ImageFont.truetype(FONTS_PATH / "montserrat.otf", font_size)
|
||||
|
||||
# 获取文本边界框
|
||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# 计算图像大小(文本大小 + 内边距)
|
||||
image_width = int(text_width + 2 * padding)
|
||||
image_height = int(text_height + 2 * padding)
|
||||
|
||||
# 创建RGBA模式的空白图像(带透明通道)
|
||||
image = Image.new('RGBA', (image_width, image_height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 绘制文本(考虑内边距)
|
||||
x = padding - bbox[0] # 调整起始位置
|
||||
y = padding - bbox[1]
|
||||
|
||||
# 设置文本颜色(带透明度)
|
||||
if len(text_color) == 3:
|
||||
text_color = text_color + (255,) # 添加完全不透明的alpha值
|
||||
|
||||
draw.text((x, y), text, fill=text_color, font=font)
|
||||
|
||||
# 转换为OpenCV格式(BGRA)
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGBA2BGRA)
|
||||
return image_cv
|
||||
|
||||
def get_random_number(min: int = 1, max: int = 6) -> int:
|
||||
import random
|
||||
return random.randint(min, max)
|
||||
def perspective_transform(image, target, corners):
|
||||
"""
|
||||
对图像进行透视变换(保持透明通道)
|
||||
target: 画布
|
||||
corners: 四个角点的坐标,顺序为 [左上, 右上, 右下, 左下]
|
||||
"""
|
||||
height, width = image.shape[:2]
|
||||
|
||||
# 源点(原始图像的四个角)
|
||||
src_points = np.array([
|
||||
[0, 0], # 左上
|
||||
[width-1, 0], # 右上
|
||||
[width-1, height-1], # 右下
|
||||
[0, height-1] # 左下
|
||||
], dtype=np.float32)
|
||||
|
||||
# 目标点(变换后的四个角)
|
||||
dst_points = np.array(corners, dtype=np.float32)
|
||||
|
||||
# 计算透视变换矩阵
|
||||
matrix = cv2.getPerspectiveTransform(src_points, dst_points)
|
||||
|
||||
# 获取画布大小
|
||||
target_height, target_width = target.shape[:2]
|
||||
|
||||
def roll_dice(wide: bool = False) -> str:
|
||||
raw = number_arts[get_random_number()]
|
||||
if wide:
|
||||
raw = (raw
|
||||
.replace("/", "/")
|
||||
.replace("\\", "\")
|
||||
.replace("_", "_")
|
||||
.replace("|", "|")
|
||||
.replace(" ", " "))
|
||||
return raw
|
||||
# 应用透视变换(保持所有通道,包括alpha)
|
||||
transformed = cv2.warpPerspective(image, matrix, (target_width, target_height), flags=cv2.INTER_LINEAR)
|
||||
|
||||
return transformed, matrix
|
||||
|
||||
def blend_with_transparency(background, foreground, position):
|
||||
"""
|
||||
将带透明通道的前景图像合成到背景图像上
|
||||
position: 前景图像在背景图像上的位置 (x, y)
|
||||
"""
|
||||
bg = background.copy()
|
||||
|
||||
# 如果背景没有alpha通道,添加一个
|
||||
if bg.shape[2] == 3:
|
||||
bg = cv2.cvtColor(bg, cv2.COLOR_BGR2BGRA)
|
||||
bg[:, :, 3] = 255 # 完全不透明
|
||||
|
||||
x, y = position
|
||||
fg_height, fg_width = foreground.shape[:2]
|
||||
bg_height, bg_width = bg.shape[:2]
|
||||
|
||||
# 确保位置在图像范围内
|
||||
x = max(0, min(x, bg_width - fg_width))
|
||||
y = max(0, min(y, bg_height - fg_height))
|
||||
|
||||
# 提取前景的alpha通道并归一化
|
||||
alpha_foreground = foreground[:, :, 3] / 255.0
|
||||
|
||||
# 对于每个颜色通道进行合成
|
||||
for c in range(3):
|
||||
bg_region = bg[y:y+fg_height, x:x+fg_width, c]
|
||||
fg_region = foreground[:, :, c]
|
||||
|
||||
# alpha混合公式
|
||||
bg[y:y+fg_height, x:x+fg_width, c] = (
|
||||
alpha_foreground * fg_region +
|
||||
(1 - alpha_foreground) * bg_region
|
||||
)
|
||||
|
||||
# 更新背景的alpha通道(如果需要)
|
||||
bg_alpha_region = bg[y:y+fg_height, x:x+fg_width, 3]
|
||||
bg[y:y+fg_height, x:x+fg_width, 3] = np.maximum(bg_alpha_region, foreground[:, :, 3])
|
||||
|
||||
return bg
|
||||
|
||||
def precise_blend_with_perspective(background, foreground, corners):
|
||||
"""
|
||||
精确合成:根据四个角点将前景图像透视合成到背景上
|
||||
"""
|
||||
# 创建与背景相同大小的空白图像
|
||||
bg_height, bg_width = background.shape[:2]
|
||||
|
||||
# 如果背景没有alpha通道,转换为BGRA
|
||||
if background.shape[2] == 3:
|
||||
background_bgra = cv2.cvtColor(background, cv2.COLOR_BGR2BGRA)
|
||||
else:
|
||||
background_bgra = background.copy()
|
||||
|
||||
# 创建与背景相同大小的前景图层
|
||||
foreground_layer = np.zeros((bg_height, bg_width, 4), dtype=np.uint8)
|
||||
|
||||
# 计算前景图像在背景中的边界框
|
||||
min_x = int(min(corners[:, 0]))
|
||||
max_x = int(max(corners[:, 0]))
|
||||
min_y = int(min(corners[:, 1]))
|
||||
max_y = int(max(corners[:, 1]))
|
||||
|
||||
# 将变换后的前景图像放置到对应位置
|
||||
fg_height, fg_width = foreground.shape[:2]
|
||||
if min_y + fg_height <= bg_height and min_x + fg_width <= bg_width:
|
||||
foreground_layer[min_y:min_y+fg_height, min_x:min_x+fg_width] = foreground
|
||||
|
||||
# 创建掩码(只在前景有内容的地方合成)
|
||||
mask = (foreground_layer[:, :, 3] > 0)
|
||||
|
||||
# 合成图像
|
||||
result = background_bgra.copy()
|
||||
for c in range(3):
|
||||
result[:, :, c][mask] = foreground_layer[:, :, c][mask]
|
||||
result[:, :, 3][mask] = foreground_layer[:, :, 3][mask]
|
||||
|
||||
return result
|
||||
|
||||
def draw_line_bresenham(image, x0, y0, x1, y1, color):
|
||||
"""使用Bresenham算法画线,避免间隙"""
|
||||
dx = abs(x1 - x0)
|
||||
dy = abs(y1 - y0)
|
||||
sx = 1 if x0 < x1 else -1
|
||||
sy = 1 if y0 < y1 else -1
|
||||
err = dx - dy
|
||||
|
||||
while True:
|
||||
if 0 <= x0 < image.shape[1] and 0 <= y0 < image.shape[0]:
|
||||
image[y0, x0] = color
|
||||
|
||||
if x0 == x1 and y0 == y1:
|
||||
break
|
||||
|
||||
e2 = 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
x0 += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def slice_and_stretch(image, slice_lines, direction):
|
||||
'''
|
||||
image: 图像
|
||||
slice_lines: 切割线(两个点的列表),一般是倾斜45度的直线
|
||||
direction: 移动方向向量(二元数组)
|
||||
'''
|
||||
# 获取图片的尺寸
|
||||
height, width = image.shape[:2]
|
||||
# 创建一个由移动方向扩充后,更大的图片
|
||||
new_width = int(width + abs(direction[0]))
|
||||
new_height = int(height + abs(direction[1]))
|
||||
new_image = np.zeros((new_height, new_width, 4), dtype=image.dtype)
|
||||
# 先把图片放在新图的和方向相反的一侧
|
||||
offset_x = int(abs(min(0, direction[0])))
|
||||
offset_y = int(abs(min(0, direction[1])))
|
||||
new_image[offset_y:offset_y+height, offset_x:offset_x+width] = image
|
||||
# 切割线也跟着偏移
|
||||
slice_lines = [(x + offset_x, y + offset_y) for (x, y) in slice_lines]
|
||||
# 复制切割线经过的像素,沿着方向移动,实现类似拖尾的效果
|
||||
apply_trail_effect_vectorized(new_image, slice_lines, direction)
|
||||
apply_stroke_vectorized(new_image, slice_lines, direction)
|
||||
|
||||
|
||||
return new_image, offset_x, offset_y
|
||||
|
||||
def apply_trail_effect_vectorized(new_image, slice_lines, direction):
|
||||
"""向量化实现拖尾效果"""
|
||||
height, width = new_image.shape[:2]
|
||||
|
||||
# 创建坐标网格
|
||||
y_coords, x_coords = np.mgrid[0:height, 0:width]
|
||||
|
||||
# 向量化计算点到直线的距离
|
||||
line_vec = np.array([slice_lines[1][0] - slice_lines[0][0],
|
||||
slice_lines[1][1] - slice_lines[0][1]])
|
||||
point_vecs = np.stack([x_coords - slice_lines[0][0],
|
||||
y_coords - slice_lines[0][1]], axis=-1)
|
||||
|
||||
# 计算叉积(有向距离)
|
||||
cross_products = (line_vec[0] * point_vecs[:, :, 1] -
|
||||
line_vec[1] * point_vecs[:, :, 0])
|
||||
|
||||
# 选择直线右侧的像素 (d1 > 0)
|
||||
mask = cross_products > 0
|
||||
|
||||
# 计算目标位置
|
||||
target_x = (x_coords + direction[0]).astype(int)
|
||||
target_y = (y_coords + direction[1]).astype(int)
|
||||
|
||||
# 创建有效位置掩码
|
||||
valid_mask = mask & (target_x >= 0) & (target_x < width) & \
|
||||
(target_y >= 0) & (target_y < height)
|
||||
|
||||
# 批量复制像素
|
||||
new_image[target_y[valid_mask], target_x[valid_mask]] = \
|
||||
new_image[y_coords[valid_mask], x_coords[valid_mask]]
|
||||
|
||||
def apply_stroke_vectorized(new_image, slice_lines, direction):
|
||||
"""使用向量化操作优化笔画效果"""
|
||||
height, width = new_image.shape[:2]
|
||||
|
||||
# 1. 找到所有非透明像素
|
||||
non_transparent = np.where(new_image[:, :, 3] > 0)
|
||||
if len(non_transparent[0]) == 0:
|
||||
return
|
||||
|
||||
y_coords, x_coords = non_transparent
|
||||
|
||||
# 2. 向量化计算点到直线的距离
|
||||
line_vec = np.array([slice_lines[1][0] - slice_lines[0][0],
|
||||
slice_lines[1][1] - slice_lines[0][1]])
|
||||
point_vecs = np.column_stack([x_coords - slice_lines[0][0],
|
||||
y_coords - slice_lines[0][1]])
|
||||
|
||||
# 计算叉积(距离)
|
||||
cross_products = (line_vec[0] * point_vecs[:, 1] -
|
||||
line_vec[1] * point_vecs[:, 0])
|
||||
|
||||
# 3. 选择靠近直线的像素
|
||||
mask = np.abs(cross_products) < 1.0
|
||||
selected_y = y_coords[mask]
|
||||
selected_x = x_coords[mask]
|
||||
selected_pixels = new_image[selected_y, selected_x]
|
||||
|
||||
if len(selected_x) == 0:
|
||||
return
|
||||
|
||||
# 4. 预计算采样点
|
||||
length = np.sqrt(direction[0]**2 + direction[1]**2)
|
||||
if length == 0:
|
||||
return
|
||||
|
||||
# 创建采样偏移
|
||||
dx_dy = np.array([(dx, dy) for dx in [-0.5, 0, 0.5]
|
||||
for dy in [-0.5, 0, 0.5]])
|
||||
|
||||
# 5. 批量计算目标位置
|
||||
steps = max(1, int(length * 2))
|
||||
alpha = 0.7
|
||||
|
||||
for k in range(1, steps + 1):
|
||||
# 对所有选中的像素批量计算新位置
|
||||
scale = k / steps
|
||||
|
||||
# 为每个像素和每个采样点计算目标位置
|
||||
for dx, dy in dx_dy:
|
||||
target_x = np.round(selected_x + dx + direction[0] * scale).astype(int)
|
||||
target_y = np.round(selected_y + dy + direction[1] * scale).astype(int)
|
||||
|
||||
# 创建有效位置掩码
|
||||
valid_mask = (target_x >= 0) & (target_x < width) & \
|
||||
(target_y >= 0) & (target_y < height)
|
||||
|
||||
if np.any(valid_mask):
|
||||
valid_target_x = target_x[valid_mask]
|
||||
valid_target_y = target_y[valid_mask]
|
||||
valid_source_idx = np.where(valid_mask)[0]
|
||||
|
||||
# 批量混合像素
|
||||
source_pixels = selected_pixels[valid_source_idx]
|
||||
target_pixels = new_image[valid_target_y, valid_target_x]
|
||||
|
||||
new_image[valid_target_y, valid_target_x] = (
|
||||
alpha * source_pixels + (1 - alpha) * target_pixels
|
||||
)
|
||||
|
||||
async def generate_dice_image(number: str) -> BytesIO:
|
||||
# 将文本转换为带透明背景的图像
|
||||
text = number
|
||||
|
||||
# 如果文本太长,直接返回金箍棒
|
||||
if(len(text) > 50):
|
||||
output = BytesIO()
|
||||
push_image = Image.open(ASSETS_PATH / "img" / "dice" / "stick.png")
|
||||
push_image.save(output,format='PNG')
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
text_image = text_to_transparent_image(
|
||||
text,
|
||||
font_size=60,
|
||||
text_color=(0, 0, 0) # 黑色文字
|
||||
)
|
||||
|
||||
# 获取长宽比
|
||||
height, width = text_image.shape[:2]
|
||||
aspect_ratio = width / height
|
||||
|
||||
# 根据长宽比设置拉伸系数
|
||||
stretch_k = 1
|
||||
if aspect_ratio > 1:
|
||||
stretch_k = aspect_ratio
|
||||
|
||||
# 骰子的方向
|
||||
up_direction = (51 - 16, 5 - 30) # 右上角点 - 左上角点
|
||||
|
||||
move_distance = (up_direction[0] * (stretch_k - 1), up_direction[1] * (stretch_k - 1))
|
||||
|
||||
# 加载背景图像,保留透明通道
|
||||
background = cv2.imread(str(ASSETS_PATH / "img" / "dice" / "template.png"), cv2.IMREAD_UNCHANGED)
|
||||
assert background is not None
|
||||
|
||||
height, width = background.shape[:2]
|
||||
|
||||
background, offset_x, offset_y = slice_and_stretch(background,
|
||||
[(10,10),(0,0)],
|
||||
move_distance)
|
||||
|
||||
# 定义3D变换的四个角点(透视效果)
|
||||
# 顺序: [左上, 右上, 右下, 左下]
|
||||
corners = np.array([
|
||||
[16, 30], # 左上
|
||||
[51 + move_distance[0], 5 + move_distance[1]], # 右上(上移,创建透视)
|
||||
[88 + move_distance[0], 33 + move_distance[1]], # 右下
|
||||
[49, 62] # 左下(下移)
|
||||
], dtype=np.float32)
|
||||
corners[:, 0] += offset_x
|
||||
corners[:, 1] += offset_y
|
||||
|
||||
|
||||
# 对文本图像进行3D变换(保持透明通道)
|
||||
transformed_text, transform_matrix = perspective_transform(text_image, background, corners)
|
||||
|
||||
min_x = int(min(corners[:, 0]))
|
||||
min_y = int(min(corners[:, 1]))
|
||||
final_image_simple = blend_with_transparency(background, transformed_text, (min_x, min_y))
|
||||
|
||||
pil_final = Image.fromarray(final_image_simple)
|
||||
# 导入一系列图像
|
||||
images: list[Image.Image] = [Image.open(ASSETS_PATH / "img" / "dice" / f"{i}.png") for i in range(1, 12)]
|
||||
images.append(pil_final)
|
||||
frame_durations = [100] * (len(images) - 1) + [100000]
|
||||
# 将导入的图像尺寸扩展为和 pil_final 相同的大小,随帧数进行扩展,然后不放大的情况下放在最中间
|
||||
if(aspect_ratio > 1):
|
||||
target_size = pil_final.size
|
||||
for i in range(len(images) - 1):
|
||||
k = i / (len(images) - 1)
|
||||
now_distance = (move_distance[0] * k, move_distance[1] * k)
|
||||
img = np.array(images[i])
|
||||
img, _, _ = slice_and_stretch(img,
|
||||
[(10,10),(0,0)],
|
||||
now_distance)
|
||||
# 只扩展边界,图像本身不放大
|
||||
img_width, img_height = img.shape[1], img.shape[0]
|
||||
new_img = Image.new("RGBA", target_size, (0, 0, 0, 0))
|
||||
this_offset_x = (target_size[0] - img_width) // 2
|
||||
this_offset_y = (target_size[1] - img_height) // 2
|
||||
# new_img.paste(img, (this_offset_x, this_offset_y))
|
||||
new_img.paste(Image.fromarray(img), (this_offset_x, this_offset_y))
|
||||
images[i] = new_img
|
||||
|
||||
|
||||
# 保存为BytesIO对象
|
||||
output = BytesIO()
|
||||
images[0].save(output,
|
||||
save_all=True,
|
||||
append_images=images[1:],
|
||||
duration=frame_durations,
|
||||
format='GIF',
|
||||
loop=1)
|
||||
output.seek(0)
|
||||
# pil_final.save(output, format='PNG')
|
||||
return output
|
||||
72
konabot/plugins/roll_dice/roll_number.py
Normal file
@ -0,0 +1,72 @@
|
||||
number_arts = {
|
||||
1: ''' _
|
||||
/ |
|
||||
| |
|
||||
| |
|
||||
|_|
|
||||
|
||||
''',
|
||||
2: ''' ____
|
||||
|___ \\
|
||||
__) |
|
||||
/ __/
|
||||
|_____|
|
||||
''',
|
||||
3: ''' _____
|
||||
|___ /
|
||||
|_ \\
|
||||
___) |
|
||||
|____/
|
||||
''',
|
||||
4: ''' _ _
|
||||
| || |
|
||||
| || |_
|
||||
|__ _|
|
||||
|_|
|
||||
''',
|
||||
5: ''' ____
|
||||
| ___|
|
||||
|___ \\
|
||||
___) |
|
||||
|____/
|
||||
''',
|
||||
6: ''' __
|
||||
/ /_
|
||||
| '_ \\
|
||||
| (_) |
|
||||
\\___/
|
||||
'''
|
||||
}
|
||||
|
||||
def get_random_number(min: int = 1, max: int = 6) -> int:
|
||||
import random
|
||||
return random.randint(min, max)
|
||||
|
||||
def get_random_number_string(min_value: str = "1", max_value: str = "6") -> str:
|
||||
import random
|
||||
|
||||
# 先判断二者是不是整数
|
||||
if (float(min_value).is_integer()
|
||||
and float(max_value).is_integer()
|
||||
and "." not in min_value
|
||||
and "." not in max_value):
|
||||
return str(random.randint(int(float(min_value)), int(float(max_value))))
|
||||
|
||||
# 根据传入小数的位数,决定保留几位小数
|
||||
if "." in str(min_value) or "." in str(max_value):
|
||||
decimal_places = max(len(str(min_value).split(".")[1]) if "." in str(min_value) else 0,
|
||||
len(str(max_value).split(".")[1]) if "." in str(max_value) else 0)
|
||||
return str(round(random.uniform(float(min_value), float(max_value)), decimal_places))
|
||||
|
||||
# 如果没有小数点,很可能二者都是指数表示或均为 inf,直接返回随机小数
|
||||
return str(random.uniform(float(min_value), float(max_value)))
|
||||
def roll_number(wide: bool = False) -> str:
|
||||
raw = number_arts[get_random_number()]
|
||||
if wide:
|
||||
raw = (raw
|
||||
.replace("/", "/")
|
||||
.replace("\\", "\")
|
||||
.replace("_", "_")
|
||||
.replace("|", "|")
|
||||
.replace(" ", " "))
|
||||
return raw
|
||||
219
konabot/plugins/simple_notify/__init__.py
Normal file
@ -0,0 +1,219 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
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 pydantic import BaseModel
|
||||
|
||||
evt = on_message()
|
||||
|
||||
(Path(__file__).parent.parent.parent.parent / "data").mkdir(exist_ok=True)
|
||||
DATA_FILE_PATH = Path(__file__).parent.parent.parent.parent / "data" / "notify.json"
|
||||
DATA_FILE_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
class Notify(BaseModel):
|
||||
platform: Literal["console", "qq", "discord"]
|
||||
|
||||
target: str
|
||||
"需要接受通知的个体"
|
||||
|
||||
target_env: str | None
|
||||
"在哪里进行通知,如果是 None 代表私聊通知"
|
||||
|
||||
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] = []
|
||||
|
||||
|
||||
def load_notify_config() -> NotifyConfigFile:
|
||||
if not DATA_FILE_PATH.exists():
|
||||
return NotifyConfigFile()
|
||||
try:
|
||||
return NotifyConfigFile.model_validate_json(DATA_FILE_PATH.read_text())
|
||||
except Exception as e:
|
||||
logger.warning(f"在解析 Notify 时遇到问题:{e}")
|
||||
return NotifyConfigFile()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def create_notify_task(notify: Notify, fail2remove: bool = True):
|
||||
async def mission():
|
||||
begin_time = datetime.datetime.now()
|
||||
if begin_time < notify.notify_time:
|
||||
await asyncio.sleep((notify.notify_time - begin_time).total_seconds())
|
||||
res = await notify_now(notify)
|
||||
if fail2remove or res:
|
||||
await DATA_FILE_LOCK.acquire()
|
||||
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)
|
||||
DATA_FILE_LOCK.release()
|
||||
else:
|
||||
pass
|
||||
return asyncio.create_task(mission())
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, mEvt: Event):
|
||||
if mEvt.get_user_id() in nonebot.get_bots():
|
||||
return
|
||||
|
||||
text = msg.extract_plain_text()
|
||||
if "提醒我" not in text:
|
||||
return
|
||||
|
||||
segments = text.split("提醒我", maxsplit=1)
|
||||
if len(segments) != 2:
|
||||
return
|
||||
|
||||
notify_time, notify_text = segments
|
||||
# target_time = get_target_time(notify_time)
|
||||
try:
|
||||
target_time = ptimeparse.parse(notify_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_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())
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
NOTIFIED_FLAG = {
|
||||
"task_added": False,
|
||||
}
|
||||
|
||||
|
||||
@driver.on_bot_connect
|
||||
async def _():
|
||||
if NOTIFIED_FLAG["task_added"]:
|
||||
return
|
||||
|
||||
NOTIFIED_FLAG["task_added"] = True
|
||||
|
||||
await asyncio.sleep(10)
|
||||
await DATA_FILE_LOCK.acquire()
|
||||
tasks = []
|
||||
cfg = load_notify_config()
|
||||
if cfg.version == 1:
|
||||
cfg.version = 2
|
||||
else:
|
||||
for notify in cfg.notifies:
|
||||
tasks.append(create_notify_task(notify, fail2remove=False))
|
||||
DATA_FILE_LOCK.release()
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
220
konabot/plugins/ytpgif/__init__.py
Normal file
@ -0,0 +1,220 @@
|
||||
from io import BytesIO
|
||||
|
||||
from loguru import logger
|
||||
from nonebot.adapters import Bot as BaseBot
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot_plugin_alconna import Alconna, Args, Field, UniMessage, on_alconna
|
||||
from PIL import Image
|
||||
from returns.result import Failure, Success
|
||||
|
||||
from konabot.common.nb.extract_image import extract_image_from_message
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="ytpgif",
|
||||
description="生成来回镜像翻转的仿 YTPMV 动图。",
|
||||
usage="ytpgif [倍速=1.0] (倍速范围:0.1~20.0)",
|
||||
type="application",
|
||||
config=None,
|
||||
homepage=None,
|
||||
)
|
||||
|
||||
# 参数定义
|
||||
BASE_SEGMENT_DURATION = 0.25
|
||||
BASE_INTERVAL = 0.25
|
||||
MAX_SIZE = 256
|
||||
MIN_SPEED = 0.1
|
||||
MAX_SPEED = 20.0
|
||||
MAX_FRAMES_PER_SEGMENT = 500
|
||||
|
||||
# 提示语
|
||||
SPEED_TIPS = f"倍速必须是 {MIN_SPEED} 到 {MAX_SPEED} 之间的数字"
|
||||
|
||||
|
||||
# 定义命令 + 参数校验
|
||||
ytpgif_cmd = on_alconna(
|
||||
Alconna(
|
||||
"ytpgif",
|
||||
Args[
|
||||
"speed?",
|
||||
float,
|
||||
Field(
|
||||
default=1.0,
|
||||
unmatch_tips=lambda x: f"“{x}”不是有效数值。{SPEED_TIPS}",
|
||||
),
|
||||
],
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=False,
|
||||
)
|
||||
|
||||
|
||||
def resize_frame(frame: Image.Image) -> Image.Image:
|
||||
"""缩放图像,保持宽高比,不超过 MAX_SIZE"""
|
||||
w, h = frame.size
|
||||
if w <= MAX_SIZE and h <= MAX_SIZE:
|
||||
return frame
|
||||
|
||||
scale = MAX_SIZE / max(w, h)
|
||||
new_w = int(w * scale)
|
||||
new_h = int(h * scale)
|
||||
return frame.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
@ytpgif_cmd.handle()
|
||||
async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0):
|
||||
# === 校验 speed 范围 ===
|
||||
if not (MIN_SPEED <= speed <= MAX_SPEED):
|
||||
await ytpgif_cmd.send(
|
||||
await UniMessage.text(f"❌ {SPEED_TIPS}").export()
|
||||
)
|
||||
return
|
||||
|
||||
match await extract_image_from_message(event.get_message(), event, bot):
|
||||
case Success(img):
|
||||
src_img = img
|
||||
|
||||
case Failure(msg):
|
||||
await ytpgif_cmd.send(
|
||||
await UniMessage.text(msg).export()
|
||||
)
|
||||
return
|
||||
|
||||
case _:
|
||||
return
|
||||
|
||||
try:
|
||||
try:
|
||||
n_frames = getattr(src_img, "n_frames", 1)
|
||||
is_animated = n_frames > 1
|
||||
logger.debug(f"收到的动图的运动状态:{is_animated} 帧数量:{n_frames}")
|
||||
except Exception:
|
||||
is_animated = False
|
||||
n_frames = 1
|
||||
|
||||
output_frames = []
|
||||
output_durations_ms = []
|
||||
|
||||
if is_animated:
|
||||
# === 动图模式:截取正向 + 镜像两段 ===
|
||||
frames_with_duration: list[tuple[Image.Image, float]] = []
|
||||
palette = src_img.getpalette()
|
||||
|
||||
for idx in range(n_frames):
|
||||
src_img.seek(idx)
|
||||
frame = src_img.copy()
|
||||
# 检查是否需要透明通道
|
||||
has_alpha = (
|
||||
frame.mode in ("RGBA", "LA")
|
||||
or (frame.mode == "P" and "transparency" in frame.info)
|
||||
)
|
||||
if has_alpha:
|
||||
frame = frame.convert("RGBA")
|
||||
else:
|
||||
frame = frame.convert("RGB")
|
||||
resized_frame = resize_frame(frame)
|
||||
|
||||
# 若原图有调色板,尝试保留(可选)
|
||||
if palette and resized_frame.mode == "P":
|
||||
try:
|
||||
resized_frame.putpalette(palette)
|
||||
except Exception: # noqa
|
||||
logger.debug("色板应用失败")
|
||||
pass
|
||||
|
||||
ms = frame.info.get("duration", int(BASE_SEGMENT_DURATION * 1000))
|
||||
dur_sec = max(0.01, ms / 1000.0)
|
||||
frames_with_duration.append((resized_frame, dur_sec))
|
||||
|
||||
max_dur = BASE_SEGMENT_DURATION * speed
|
||||
accumulated = 0.0
|
||||
frame_count = 0
|
||||
|
||||
# 正向段
|
||||
for img, dur in frames_with_duration:
|
||||
if accumulated + dur > max_dur or frame_count >= MAX_FRAMES_PER_SEGMENT:
|
||||
break
|
||||
output_frames.append(img)
|
||||
output_durations_ms.append(int(dur * 1000))
|
||||
accumulated += dur
|
||||
frame_count += 1
|
||||
|
||||
if frame_count == 0:
|
||||
await ytpgif_cmd.send(
|
||||
await UniMessage.text("动图帧太短,无法生成有效片段。").export()
|
||||
)
|
||||
return
|
||||
|
||||
# 镜像段(从头开始)
|
||||
accumulated = 0.0
|
||||
frame_count = 0
|
||||
for img, dur in frames_with_duration:
|
||||
if accumulated + dur > max_dur or frame_count >= MAX_FRAMES_PER_SEGMENT:
|
||||
break
|
||||
flipped = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
output_frames.append(flipped)
|
||||
output_durations_ms.append(int(dur * 1000))
|
||||
accumulated += dur
|
||||
frame_count += 1
|
||||
|
||||
else:
|
||||
# === 静态图模式:制作翻转动画 ===
|
||||
raw_frame = src_img.convert("RGBA")
|
||||
resized_frame = resize_frame(raw_frame)
|
||||
|
||||
interval_sec = max(0.025, min(2.5, BASE_INTERVAL / speed))
|
||||
duration_ms = int(interval_sec * 1000)
|
||||
|
||||
frame1 = resized_frame
|
||||
frame2 = resized_frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
|
||||
output_frames = [frame1, frame2]
|
||||
output_durations_ms = [duration_ms, duration_ms]
|
||||
|
||||
if len(output_frames) < 1:
|
||||
await ytpgif_cmd.send(
|
||||
await UniMessage.text("未能生成任何帧。").export()
|
||||
)
|
||||
return
|
||||
|
||||
# === 🔐 关键修复:防止无透明图的颜色被当成透明 ===
|
||||
need_transparency = False
|
||||
for frame in output_frames:
|
||||
if frame.mode == "RGBA":
|
||||
alpha_channel = frame.getchannel("A")
|
||||
if any(pix < 255 for pix in alpha_channel.getdata()):
|
||||
need_transparency = True
|
||||
break
|
||||
elif frame.mode == "P" and "transparency" in frame.info:
|
||||
need_transparency = True
|
||||
break
|
||||
|
||||
# 如果不需要透明,则统一转为 RGB 避免调色板污染
|
||||
if not need_transparency:
|
||||
output_frames = [f.convert("RGB") for f in output_frames]
|
||||
|
||||
# 构建保存参数
|
||||
save_kwargs = {
|
||||
"save_all": True,
|
||||
"append_images": output_frames[1:],
|
||||
"format": "GIF",
|
||||
"loop": 0, # 无限循环
|
||||
"duration": output_durations_ms,
|
||||
"disposal": 2, # 清除到背景色,避免残留
|
||||
"optimize": False, # 关闭抖动(等效 -dither none)
|
||||
}
|
||||
|
||||
# 只有真正需要透明时才启用 transparency
|
||||
if need_transparency:
|
||||
save_kwargs["transparency"] = 0
|
||||
|
||||
bio = BytesIO()
|
||||
output_frames[0].save(bio, **save_kwargs)
|
||||
result_image = UniMessage.image(raw=bio)
|
||||
await ytpgif_cmd.send(await result_image.export())
|
||||
except Exception as e:
|
||||
print(f"[YTPGIF] 处理失败: {e}")
|
||||
await ytpgif_cmd.send(
|
||||
await UniMessage.text("❌ 处理失败,可能是图片格式不支持、文件损坏或过大。").export()
|
||||
)
|
||||
123
poetry.lock
generated
@ -1727,6 +1727,91 @@ files = [
|
||||
[package.dependencies]
|
||||
textual = ">=3.7.0,<4.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.2.6"
|
||||
description = "Fundamental package for array computing in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"},
|
||||
{file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"},
|
||||
{file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"},
|
||||
{file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"},
|
||||
{file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"},
|
||||
{file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"},
|
||||
{file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"},
|
||||
{file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"},
|
||||
{file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"},
|
||||
{file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"},
|
||||
{file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencv-python-headless"
|
||||
version = "4.12.0.88"
|
||||
description = "Wrapper package for OpenCV python bindings."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opencv-python-headless-4.12.0.88.tar.gz", hash = "sha256:cfdc017ddf2e59b6c2f53bc12d74b6b0be7ded4ec59083ea70763921af2b6c09"},
|
||||
{file = "opencv_python_headless-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1e58d664809b3350c1123484dd441e1667cd7bed3086db1b9ea1b6f6cb20b50e"},
|
||||
{file = "opencv_python_headless-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:365bb2e486b50feffc2d07a405b953a8f3e8eaa63865bc650034e5c71e7a5154"},
|
||||
{file = "opencv_python_headless-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aeb4b13ecb8b4a0beb2668ea07928160ea7c2cd2d9b5ef571bbee6bafe9cc8d0"},
|
||||
{file = "opencv_python_headless-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:236c8df54a90f4d02076e6f9c1cc763d794542e886c576a6fee46ec8ff75a7a9"},
|
||||
{file = "opencv_python_headless-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:fde2cf5c51e4def5f2132d78e0c08f9c14783cd67356922182c6845b9af87dbd"},
|
||||
{file = "opencv_python_headless-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:86b413bdd6c6bf497832e346cd5371995de148e579b9774f8eba686dee3f5528"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
numpy = {version = ">=2,<2.3.0", markers = "python_version >= \"3.9\""}
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.3.0"
|
||||
@ -1977,6 +2062,23 @@ files = [
|
||||
{file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptimeparse"
|
||||
version = "0.1.2"
|
||||
description = "一个用于解析中文的时间表达的库"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "ptimeparse-0.1.2-py3-none-any.whl", hash = "sha256:0eea791396e53b63330fadb40d9f0a2e6272bd5467246f10d1d6971bc606edff"},
|
||||
{file = "ptimeparse-0.1.2.tar.gz", hash = "sha256:658be90a3cc2994c09c4ea2f276d257e7eb84bc330be79950baefe32b19779a2"},
|
||||
]
|
||||
|
||||
[package.source]
|
||||
type = "legacy"
|
||||
url = "https://gitea.service.jazzwhom.top/api/packages/Passthem/pypi/simple"
|
||||
reference = "pt-gitea-pypi"
|
||||
|
||||
[[package]]
|
||||
name = "pycares"
|
||||
version = "4.11.0"
|
||||
@ -2375,6 +2477,25 @@ urllib3 = ">=1.21.1,<3"
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "returns"
|
||||
version = "0.26.0"
|
||||
description = "Make your functions return something meaningful, typed, and safe!"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "returns-0.26.0-py3-none-any.whl", hash = "sha256:7cae94c730d6c56ffd9d0f583f7a2c0b32cfe17d141837150c8e6cff3eb30d71"},
|
||||
{file = "returns-0.26.0.tar.gz", hash = "sha256:180320e0f6e9ea9845330ccfc020f542330f05b7250941d9b9b7c00203fcc3da"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.0,<5.0"
|
||||
|
||||
[package.extras]
|
||||
check-laws = ["hypothesis (>=6.136,<7.0)", "pytest (>=8.0,<9.0)"]
|
||||
compatible-mypy = ["mypy (>=1.12,<1.18)"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.1.0"
|
||||
@ -3077,4 +3198,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.12,<4.0"
|
||||
content-hash = "ca1f92dc64b99018d4b1043c984b1e52d325af213e3af77370855a6b00bd77e0"
|
||||
content-hash = "b4c3d28f7572c57e867d126ce0c64787ae608b114e66b8de06147caf13e049dd"
|
||||
|
||||
@ -20,9 +20,19 @@ dependencies = [
|
||||
"lxml (>=6.0.2,<7.0.0)",
|
||||
"pillow (>=11.3.0,<12.0.0)",
|
||||
"imagetext-py (>=2.2.0,<3.0.0)",
|
||||
"opencv-python-headless (>=4.12.0.88,<5.0.0.0)",
|
||||
"returns (>=0.26.0,<0.27.0)",
|
||||
"ptimeparse (>=0.1.1,<0.2.0)",
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "pt-gitea-pypi"
|
||||
url = "https://gitea.service.jazzwhom.top/api/packages/Passthem/pypi/simple/"
|
||||
priority = "supplemental"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
ptimeparse = {source = "pt-gitea-pypi"}
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
aio-mc-rcon==3.4.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
aiodns==3.5.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
aiohappyeyeballs==2.6.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
aiohttp==3.12.15 ; python_version >= "3.12" and python_version < "4.0"
|
||||
aiosignal==1.4.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
anyio==4.11.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
apscheduler==3.11.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
arclet-alconna-tools==0.7.11 ; python_version >= "3.12" and python_version < "4.0"
|
||||
arclet-alconna==1.8.40 ; python_version >= "3.12" and python_version < "4.0"
|
||||
attrs==25.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
beautifulsoup4==4.13.5 ; python_version >= "3.12" and python_version < "4.0"
|
||||
brotli==1.1.0 ; python_version >= "3.12" and python_version < "4.0" and platform_python_implementation == "CPython"
|
||||
brotlicffi==1.1.0.0 ; python_version >= "3.12" and python_version < "4.0" and platform_python_implementation != "CPython"
|
||||
certifi==2025.8.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
cffi==2.0.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
charset-normalizer==3.4.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
click==8.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows")
|
||||
exceptiongroup==1.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
fastapi==0.117.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
frozenlist==1.7.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
h11==0.16.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
h2==4.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
hpack==4.1.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
httpcore==1.0.9 ; python_version >= "3.12" and python_version < "4.0"
|
||||
httptools==0.6.4 ; python_version >= "3.12" and python_version < "4.0"
|
||||
httpx==0.28.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
hyperframe==6.1.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
idna==3.10 ; python_version >= "3.12" and python_version < "4.0"
|
||||
imagetext-py==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
importlib-metadata==8.7.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
linkify-it-py==2.0.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
loguru==0.7.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
lxml==6.0.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
markdown-it-py==4.0.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
mdit-py-plugins==0.5.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
mdurl==0.1.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
msgpack==1.1.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
multidict==6.6.4 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nepattern==0.7.7 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-adapter-console==0.9.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-adapter-discord==0.1.8 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-adapter-minecraft==1.5.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-adapter-onebot==2.4.6 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-plugin-alconna==0.59.4 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-plugin-apscheduler==0.5.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot-plugin-waiter==0.8.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonebot2==2.4.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
nonechat==0.6.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pillow==11.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
platformdirs==4.4.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
propcache==0.3.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pycares==4.11.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pycparser==2.23 ; python_version >= "3.12" and python_version < "4.0" and implementation_name != "PyPy"
|
||||
pydantic-core==2.33.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pydantic==2.11.9 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pygments==2.19.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pygtrie==2.5.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
python-dotenv==1.1.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
requests==2.32.5 ; python_version >= "3.12" and python_version < "4.0"
|
||||
rich==14.1.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
sniffio==1.3.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
soupsieve==2.8 ; python_version >= "3.12" and python_version < "4.0"
|
||||
starlette==0.48.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
tarina==0.6.8 ; python_version >= "3.12" and python_version < "4.0"
|
||||
textual==3.7.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
typing-inspection==0.4.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
tzdata==2025.2 ; python_version >= "3.12" and python_version < "4.0" and platform_system == "Windows"
|
||||
tzlocal==5.3.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
uc-micro-py==1.0.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
urllib3==2.5.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
uvicorn==0.37.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
uvloop==0.21.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy"
|
||||
watchfiles==1.1.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
websockets==15.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
win32-setctime==1.2.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
||||
yarl==1.20.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
zipp==3.23.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
0
scripts/__init__.py
Normal file
24
scripts/test_plugin_load.py
Normal file
@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
from loguru import logger
|
||||
|
||||
nonebot.init()
|
||||
nonebot.load_plugins("konabot/plugins")
|
||||
|
||||
plugins = nonebot.get_loaded_plugins()
|
||||
len_requires = len(
|
||||
[
|
||||
f
|
||||
for f in (Path(__file__).parent.parent / "konabot" / "plugins").iterdir()
|
||||
if (f.is_dir() and (f / "__init__.py").exists())
|
||||
or ((not f.is_dir()) and f.suffix == ".py")
|
||||
]
|
||||
)
|
||||
|
||||
plugins = [p for p in plugins if p.module.__name__.startswith("konabot.plugins")]
|
||||
|
||||
logger.info(f"已经加载的插件数量 {len(plugins)}")
|
||||
logger.info(f"期待加载的插件数量 {len_requires}")
|
||||
|
||||
assert len(plugins) == len_requires
|
||||