Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eff25435e3 | |||
| df28fad697 | |||
| 561f6981aa | |||
| 2632215af9 | |||
| bfde559892 | |||
| 857f8c5955 | |||
| 500053e630 | |||
| 30cfb4cadd | |||
| e2f99af73b | |||
| e09de9eeb6 | |||
| 4a3b49ce79 | |||
| 03900f4416 | |||
| 62f4195e46 | |||
| 751297e3bc | |||
| b450998f3f | |||
| ae6297b98d | |||
| dacae29054 | |||
| 8acb546c6a | |||
| 49e0914416 | |||
| 5b74c78ec3 | |||
| c911410276 | |||
| 37ca4bf11f | |||
| 8ef084c22a | |||
| 57f0cd728f | |||
| 627a29f57e | |||
| 650c500f47 | |||
| 86acbe51e9 | |||
| 4900a7e0ad | |||
| 34da08126b | |||
| 00f416c8bc | |||
| 9c7d0a4486 | |||
| e3b9d6723f | |||
| ef80399a90 | |||
| bfbfa9d9be | |||
| 6b7be4d3b0 | |||
| 7c19c52d9f | |||
| a5f4ae9bdc | |||
| 9320815d3f | |||
| 795300cb83 | |||
| 0231aa04f4 | |||
| 01fe33eb9f | |||
| adfbac7d90 | |||
| 994c1412da | |||
| 8780dfec6f | |||
| 490d807e7a | |||
| fa208199ab | |||
| 38a17f42a3 | |||
| 37179fc4d7 | |||
| 56e0aabbf3 | |||
| ce2b7fd6f6 | |||
| b28f8f85a2 | |||
| 0acffea86d | |||
| 3e395f8a35 | |||
| 312e203bbe | |||
| f9deabfce0 | |||
| 0a822bf440 | |||
| 534a2c9e75 | |||
| a03cef4124 | |||
| 7a20c3fe2f | |||
| 16351792b6 | |||
| 7bbd4f81ee | |||
| 4d5678efac | |||
| c7229bb763 | |||
| 6abc963ccf | |||
| 881f38d187 | |||
| 56d32bc9f4 | |||
| 76f19f9eac | |||
| 1479d8f8da | |||
| 18785f034b | |||
| 7ba1a92623 | |||
| f6670eb672 | |||
| eb32c1af9a | |||
| e0c55545ec | |||
| 164305e81f | |||
| 96679033f3 | |||
| afda0680ec | |||
| 021133954e | |||
| 7baa04dbc2 | |||
| e55bdbdf4a | |||
| a30c7b8093 | |||
| 3da2c2266f | |||
| 96e3c3fe17 | |||
| 851c9eb3c7 | |||
| 11269b2a5a | |||
| 875e0efc2f | |||
| 4f43312663 | |||
| b2f4768573 | |||
| bc6263ec31 | |||
| bc9d025836 | |||
| b552aacf89 | |||
| f9a0249772 | |||
| c94db33b11 | |||
| 67382a0c0a | |||
| fd4c9302c2 | |||
| f30ad0cb7d | |||
| f7afe48680 | |||
| b42385f780 | |||
| 6cae38dea9 | |||
| 8594b59783 | |||
| f768c91430 | |||
| a65cb118cc | |||
| 75c6bbd23f | |||
| aaf0a75d65 | |||
| 8f560ce1ba | |||
| 9f3f79f51d | |||
| 92048aeff7 | |||
| 81aac10665 | |||
| 3ce230adfe | |||
| 4f885554ca | |||
| 7ebcb8add4 | |||
| e18cc82792 | |||
| eb28cd0a0c | |||
| 2d688a6ed6 | |||
| e9aac52200 | |||
| 4305548ab5 | |||
| 99382a3bf5 | |||
| 92e43785bf | |||
| fc5b11c5e8 | |||
| 0ec66988fa | |||
| e5c3081c22 | |||
| 14b356120a | |||
| a208302cb9 | |||
| 01ffa451bb | |||
| 2b6c2e84bd | |||
| 4f0a9af2dc | |||
| 4a4aa6b243 | |||
| 4c8625ae02 | |||
| c5f820a1f9 | |||
| a3dd2dbbda | |||
| 8d4f74dafe | |||
| 7c1bac64c9 | |||
| e09fa13d0f | |||
| 990a622cf6 | |||
| 6144563d4d | |||
| a6413c9809 | |||
| af566888ab | |||
| e72bc283f8 | |||
| c9d58e7498 | |||
| 627a48da1c | |||
| 87be1916ee | |||
| 0ca901e7b1 | |||
| d096f43d38 | |||
| 38ae3d1c74 | |||
| a0483d1d5c | |||
| ae83b66908 | |||
| 6abeb05a18 | |||
| 9b0a0368fa | |||
| 4eac493de4 | |||
| b4e400b626 | |||
| c35ee57976 | |||
| 8edb999050 | |||
| 109a81923f | |||
| 91687fb8c3 | |||
| f889381cce | |||
| 1256055c9d | |||
| 40f35a474e | |||
| 6b01acfa8c |
30
.drone.yml
@ -10,6 +10,10 @@ trigger:
|
||||
- master
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine/git
|
||||
commands:
|
||||
- git submodule update --init --recursive
|
||||
- name: 构建 Docker 镜像
|
||||
image: plugins/docker:latest
|
||||
privileged: true
|
||||
@ -34,6 +38,17 @@ steps:
|
||||
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
|
||||
- name: 发送构建结果到 ntfy
|
||||
image: parrazam/drone-ntfy
|
||||
when:
|
||||
status: [success, failure]
|
||||
settings:
|
||||
url: https://ntfy.service.jazzwhom.top
|
||||
topic: drone_ci
|
||||
tags:
|
||||
- drone-ci
|
||||
token:
|
||||
from_secret: NTFY_TOKEN
|
||||
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
@ -50,6 +65,10 @@ trigger:
|
||||
- tag
|
||||
|
||||
steps:
|
||||
- name: submodules
|
||||
image: alpine/git
|
||||
commands:
|
||||
- git submodule update --init --recursive
|
||||
- name: 构建并推送 Release Docker 镜像
|
||||
image: plugins/docker:latest
|
||||
privileged: true
|
||||
@ -66,6 +85,17 @@ steps:
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
path: /var/run/docker.sock
|
||||
- name: 发送构建结果到 ntfy
|
||||
image: parrazam/drone-ntfy
|
||||
when:
|
||||
status: [success, failure]
|
||||
settings:
|
||||
url: https://ntfy.service.jazzwhom.top
|
||||
topic: drone_ci
|
||||
tags:
|
||||
- drone-ci
|
||||
token:
|
||||
from_secret: NTFY_TOKEN
|
||||
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
|
||||
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "assets/lexicon/THUOCL"]
|
||||
path = assets/lexicon/THUOCL
|
||||
url = https://github.com/thunlp/THUOCL.git
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.REPL.enableREPLSmartSend": false
|
||||
}
|
||||
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 并启用自动重载功能"
|
||||
}
|
||||
]
|
||||
}
|
||||
48
Dockerfile
@ -1,8 +1,50 @@
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers
|
||||
|
||||
# 安装所有都需要的底层依赖
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libfontconfig1 libgl1 libegl1 libglvnd0 mesa-vulkan-drivers at-spi2-common fontconfig \
|
||||
libasound2-data libavahi-client3 libavahi-common-data libavahi-common3 libdatrie1 \
|
||||
libfontenc1 libfribidi0 libgraphite2-3 libharfbuzz0b libice6 libpixman-1-0 \
|
||||
libsm6 libthai-data libthai0 libunwind8 libxaw7 libxcb-render0 libxfont2 libxi6 \
|
||||
libxkbfile1 libxmu6 libxpm4 libxrender1 libxt6t64 x11-common x11-xkb-utils \
|
||||
xfonts-encodings xfonts-utils xkb-data xserver-common libnspr4 libatk1.0-0t64 \
|
||||
libatk-bridge2.0-0t64 libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 \
|
||||
libxkbcommon0 libasound2t64 libnss3 fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir playwright \
|
||||
&& python -m playwright install chromium \
|
||||
&& pip uninstall -y playwright
|
||||
|
||||
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN uv sync --no-install-project
|
||||
|
||||
|
||||
|
||||
FROM base AS runtime
|
||||
|
||||
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt --no-deps
|
||||
|
||||
COPY bot.py pyproject.toml .env.prod .env.test ./
|
||||
COPY assets ./assets
|
||||
|
||||
14
README.md
@ -1,4 +1,4 @@
|
||||
# 此方 bot!
|
||||
# konabot
|
||||
|
||||
在 MTTU 内部使用的 bot 一只。
|
||||
|
||||
@ -63,12 +63,20 @@ code .
|
||||
|
||||
配置 `ENABLE_CONSOLE=false`
|
||||
|
||||
#### 配置并支持 LLM(大语言模型)
|
||||
|
||||
详见[LLM 配置文档](/docs/LLM.md)。
|
||||
|
||||
#### 配置 konabot-web 以支持更高级的图片渲染
|
||||
|
||||
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
||||
|
||||
### 运行
|
||||
|
||||
你可以在 VSCode 的「运行与调试」窗口,启动 `运行 Bot 并调试(自动重载)` 任务来启动 Bot,也可以使用命令行手动启动 Bot:
|
||||
使用命令行手动启动 Bot:
|
||||
|
||||
```bash
|
||||
poetry run watchfiles bot.main
|
||||
poetry run just watch
|
||||
```
|
||||
|
||||
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
||||
|
||||
BIN
assets/img/ac/ac.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
assets/img/ac/broken_ac.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/img/ac/frozen_ac.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/img/dog/haha_dog.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/img/dog/haoba_dog.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/img/meme/anan_base.png
Normal file
|
After Width: | Height: | Size: 841 KiB |
BIN
assets/img/meme/anan_top.png
Normal file
|
After Width: | Height: | Size: 821 KiB |
BIN
assets/img/meme/caoimg1.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/img/meme/doubao.png
Executable file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
assets/img/meme/kiosay.jpg
Executable file
|
After Width: | Height: | Size: 71 KiB |
BIN
assets/img/meme/snaur_1_base.png
Executable file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/img/meme/snaur_1_top.png
Executable file
|
After Width: | Height: | Size: 1008 KiB |
BIN
assets/img/meme/tententen.png
Normal file
|
After Width: | Height: | Size: 614 KiB |
BIN
assets/img/other/boom.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
1
assets/json/poll.json
Normal file
@ -0,0 +1 @@
|
||||
{"poll": {"0": {"create": 1760357553, "expiry": 1760443953, "options": {"0": "此方bot", "1": "testpilot", "2": "小镜bot", "3": "可怜bot"}, "polldata": {}, "qq": "2975499623", "title": "我~是~谁~?"}}}
|
||||
1
assets/lexicon/THUOCL
Submodule
1
assets/lexicon/ci.json
Normal file
360393
assets/lexicon/common.txt
Normal file
339847
assets/lexicon/idiom.json
Normal file
BIN
assets/webpage/ac/assets/background.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
76
assets/webpage/ac/index.html
Normal file
@ -0,0 +1,76 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>空调炸炸排行榜</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="text">位居全球第 <span id="ranking" class="ranking">200</span>!</div>
|
||||
<div class="text-2">您的群总共坏了 <span id="number" class="number">200</span> 台空调</div>
|
||||
<img class="background" src="./assets/background.png" alt="空调炸炸排行榜">
|
||||
</div>
|
||||
</body>
|
||||
<style>
|
||||
.box {
|
||||
position: relative;
|
||||
width: 1024px;
|
||||
}
|
||||
.number {
|
||||
font-size: 2em;
|
||||
color: #ffdd00;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bold;
|
||||
font-stretch: 50%;
|
||||
max-width: 520px;
|
||||
word-wrap: break-word;
|
||||
line-height: 0.8em;
|
||||
}
|
||||
.background {
|
||||
width: 1024px;
|
||||
}
|
||||
.text {
|
||||
position: absolute;
|
||||
top: 125px;
|
||||
width: 100%;
|
||||
font-size: 72px;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bolder;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
.text-2 {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
width: 100%;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bolder;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
.ranking {
|
||||
font-size: 2em;
|
||||
color: #ff0000;
|
||||
-webkit-text-stroke: #ffffff 2px;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bold;
|
||||
font-stretch: 50%;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// 从 URL 参数中获取 number 的值
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const number = urlParams.get('number');
|
||||
// 将 number 显示在页面上
|
||||
document.getElementById('number').textContent = number;
|
||||
// 从 URL 参数中获取 ranking 的值
|
||||
const ranking = urlParams.get('ranking');
|
||||
// 将 ranking 显示在页面上
|
||||
document.getElementById('ranking').textContent = ranking;
|
||||
</script>
|
||||
</html>
|
||||
14
bot.py
@ -7,6 +7,10 @@ from nonebot.adapters.discord import Adapter as DiscordAdapter
|
||||
from nonebot.adapters.minecraft import Adapter as MinecraftAdapter
|
||||
from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter
|
||||
|
||||
from konabot.common.log import init_logger
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
from konabot.common.path import LOG_PATH
|
||||
|
||||
dotenv.load_dotenv()
|
||||
env = os.environ.get("ENVIRONMENT", "prod")
|
||||
env_enable_console = os.environ.get("ENABLE_CONSOLE", "none")
|
||||
@ -14,7 +18,16 @@ env_enable_qq = os.environ.get("ENABLE_QQ", "none")
|
||||
env_enable_discord = os.environ.get("ENABLE_DISCORD", "none")
|
||||
env_enable_minecraft = os.environ.get("ENABLE_MINECRAFT", "none")
|
||||
|
||||
|
||||
def main():
|
||||
if env.upper() == 'DEBUG' or env.upper() == 'DEV':
|
||||
console_log_level = 'DEBUG'
|
||||
else:
|
||||
console_log_level = 'INFO'
|
||||
init_logger(LOG_PATH, [
|
||||
BotExceptionMessage,
|
||||
], console_log_level=console_log_level)
|
||||
|
||||
nonebot.init()
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
@ -33,6 +46,7 @@ def main():
|
||||
|
||||
# nonebot.load_builtin_plugin("echo")
|
||||
nonebot.load_plugins("konabot/plugins")
|
||||
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
|
||||
|
||||
nonebot.run()
|
||||
|
||||
|
||||
65
docs/LLM.md
Normal file
@ -0,0 +1,65 @@
|
||||
# 大语言模型平台接入
|
||||
|
||||
为实现更多神秘小功能,此方 Bot 需要接入 AI。如果你需要参与开发或测试涉及 AI 的相关功能,麻烦请根据下面的文档继续操作。
|
||||
|
||||
## 配置项目接入 AI
|
||||
|
||||
AI 相关的配置文件在 `data/config/llm.json` 文件中。示例格式如下,这也将是到时候在云端的配置文件格式(给出的模型都会有):
|
||||
|
||||
```json
|
||||
{
|
||||
"llms": {
|
||||
"Qwen2.5-7B-Instruct": {
|
||||
"base_url": "https://api.siliconflow.cn/v1",
|
||||
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"model_name": "Qwen/Qwen2.5-7B-Instruct"
|
||||
},
|
||||
"qwen3-max": {
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"model_name": "qwen3-max"
|
||||
}
|
||||
},
|
||||
"default_llm": "Qwen2.5-7B-Instruct"
|
||||
}
|
||||
```
|
||||
|
||||
其中,形如 `qwen3-max` 的名称,是你在程序中调用 LLM 使用的键名。若不给出,则会默认使用配置文件中指定的默认模型。
|
||||
|
||||
```python
|
||||
from konabot.common.llm import get_llm
|
||||
|
||||
llm = get_llm() # 获得的是 Qwen2.5-7B-Instruct 模型
|
||||
llm = get_llm("qwen3-max") # 获得的是 qwen3-max 模型
|
||||
|
||||
message = await llm.chat([
|
||||
{ "role": "system", "content": "你是一只猫娘" },
|
||||
{ "role": "user", "content": "晚上好呀!" },
|
||||
], timeout=None, max_tokens=16384)
|
||||
# 获得了的是 openai.types.chat.ChatCompletionMessage 对象
|
||||
|
||||
print(f"AI 返回值:{message.content}") # 注意 content 可能为 None,需要做空值检测
|
||||
|
||||
client = llm.get_openai_client() # 获得的是一个 OpenAI Client 对象,可以做更多操作
|
||||
# 例如,调用 Embedding 模型来做知识库向量化等工作
|
||||
```
|
||||
|
||||
## 本项目使用的模型清单
|
||||
|
||||
为了便利大家使用,我在这里给出该项目将会使用的模型清单,请根据你的开发需求注册并选择你最喜欢的模型。如果需要接入新的模型,或者使用到文档之外的模型,欢迎在这里给出!
|
||||
|
||||
### 硅基流动 Qwen/Qwen2.5-7B-Instruct
|
||||
|
||||
一个 7B 大小的 AI 模型。其性能不太能指望,但是它小,而且比较快,可以做一些轻量的操作。
|
||||
|
||||
该模型是免费的,但是也需要你注册[硅基流动](https://cloud.siliconflow.cn/me/models)账号,并生成 `api_key` 添加到配置文件中。
|
||||
|
||||
### 通义千问 qwen3-max
|
||||
|
||||
贵但是很先进的最新模型,其能力可以信赖。但是不要拿它做大量工作哦!
|
||||
|
||||
在[百炼大模型平台](https://bailian.console.aliyun.com/)注册账号并申请 `api_key`,新用户会赠送 1M tokens,足够做测试了。
|
||||
|
||||
## 安全须知
|
||||
|
||||
请注意提防 AI 越狱等情况。
|
||||
18
docs/konabot-web.md
Normal file
@ -0,0 +1,18 @@
|
||||
# konabot-web 配置文档
|
||||
|
||||
本文档教你配置一个此方 Bot 的 Web 服务器。
|
||||
|
||||
## 安装并运行 konabot-web
|
||||
|
||||
按照 [konabot-web README](https://gitea.service.jazzwhom.top/mttu-developers/konabot-web) 安装并运行 konabot-web 实例。
|
||||
|
||||
## 指定 konabot-web 实例地址
|
||||
|
||||
如果你的 Web 服务器的端口不是 5173,或者你有特殊的网络结构,你需要手动设置 konabot-web。编辑 `.env` 文件:
|
||||
|
||||
```
|
||||
MODULE_WEB_RENDER_WEBURL=http://web-server:port
|
||||
MODULE_WEB_RENDER_INSTANCE=http://konabot-server:port
|
||||
```
|
||||
|
||||
替换 web-server 为你的前端服务器地址,konabot-server 为后端服务器地址,port 为端口号。
|
||||
4
justfile
Normal file
@ -0,0 +1,4 @@
|
||||
watch:
|
||||
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
||||
|
||||
|
||||
36
konabot/common/data_man.py
Normal file
@ -0,0 +1,36 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
class DataManager(Generic[T]):
|
||||
def __init__(self, cls: type[T], fp: Path) -> None:
|
||||
self.cls = cls
|
||||
self.fp = fp
|
||||
self._aio_lock = asyncio.Lock()
|
||||
self._data: T | None = None
|
||||
|
||||
def load(self) -> T:
|
||||
if not self.fp.exists():
|
||||
return self.cls()
|
||||
try:
|
||||
return self.cls.model_validate_json(self.fp.read_text("utf-8"))
|
||||
except ValidationError:
|
||||
return self.cls()
|
||||
|
||||
def save(self, data: T):
|
||||
self.fp.write_text(data.model_dump_json(), "utf-8")
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_data(self):
|
||||
await self._aio_lock.acquire()
|
||||
self._data = self.load()
|
||||
yield self._data
|
||||
self.save(self._data)
|
||||
self._data = None
|
||||
self._aio_lock.release()
|
||||
64
konabot/common/llm/__init__.py
Normal file
@ -0,0 +1,64 @@
|
||||
from typing import Any
|
||||
import openai
|
||||
|
||||
from loguru import logger
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from konabot.common.path import CONFIG_PATH
|
||||
|
||||
LLM_CONFIG_PATH = CONFIG_PATH / 'llm.json'
|
||||
|
||||
if not LLM_CONFIG_PATH.exists():
|
||||
LLM_CONFIG_PATH.write_text("{}")
|
||||
|
||||
|
||||
class LLMInfo(BaseModel):
|
||||
base_url: str
|
||||
api_key: str
|
||||
model_name: str
|
||||
|
||||
def get_openai_client(self):
|
||||
return openai.AsyncClient(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ChatCompletionMessageParam],
|
||||
timeout: float | None = 30.0,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatCompletionMessage:
|
||||
logger.info(f"调用 LLM: BASE_URL={self.base_url} MODEL_NAME={self.model_name}")
|
||||
completion: ChatCompletion = await self.get_openai_client().chat.completions.create(
|
||||
messages=messages,
|
||||
model=self.model_name,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
stream=False,
|
||||
**kwargs,
|
||||
)
|
||||
choice = completion.choices[0]
|
||||
logger.info(
|
||||
f"调用 LLM 完成: BASE_URL={self.base_url} MODEL_NAME={self.model_name} REASON={choice.finish_reason}"
|
||||
)
|
||||
return choice.message
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
llms: dict[str, LLMInfo] = Field(default_factory=dict)
|
||||
default_llm: str = "Qwen2.5-7B-Instruct"
|
||||
|
||||
|
||||
llm_config = LLMConfig.model_validate_json(LLM_CONFIG_PATH.read_text())
|
||||
|
||||
|
||||
def get_llm(llm_model: str | None = None):
|
||||
if llm_model is None:
|
||||
llm_model = llm_config.default_llm
|
||||
if llm_model not in llm_config.llms:
|
||||
raise NotImplementedError("LLM 未配置,该功能无法使用")
|
||||
return llm_config.llms[llm_model]
|
||||
|
||||
80
konabot/common/log.py
Normal file
@ -0,0 +1,80 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, List, Type
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
|
||||
|
||||
def file_exception_filter(
|
||||
record: "Record",
|
||||
ignored_exceptions: tuple[Type[Exception], ...]
|
||||
) -> bool:
|
||||
"""
|
||||
一个自定义的 Loguru 过滤器函数。
|
||||
如果日志记录包含异常信息,并且该异常的类型在 ignored_exceptions 中,则返回 False(忽略)。
|
||||
否则,返回 True(允许记录)。
|
||||
"""
|
||||
exception_info = record.get("exception")
|
||||
|
||||
if exception_info:
|
||||
exception_type = exception_info[0]
|
||||
|
||||
if exception_type and issubclass(exception_type, ignored_exceptions):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def init_logger(
|
||||
log_dir: Path,
|
||||
ignored_exceptions: List[Type[Exception]],
|
||||
console_log_level: str = "INFO",
|
||||
) -> None:
|
||||
"""
|
||||
配置全局 Loguru Logger。
|
||||
|
||||
Args:
|
||||
log_dir (Path): 存放日志文件的文件夹路径,会自动创建。
|
||||
ignored_exceptions (List[Type[Exception]]): 在 WARNING 级别文件日志中需要忽略的异常类型列表。
|
||||
"""
|
||||
|
||||
ignored_exceptions_tuple = tuple(ignored_exceptions)
|
||||
logger.remove()
|
||||
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=console_log_level,
|
||||
colorize=True,
|
||||
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
)
|
||||
|
||||
info_log_path = log_dir / "log.log"
|
||||
logger.add(
|
||||
str(info_log_path),
|
||||
level="INFO",
|
||||
rotation="10 MB",
|
||||
retention="7 days",
|
||||
enqueue=True,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
warning_error_log_path = log_dir / "error.log"
|
||||
logger.add(
|
||||
str(warning_error_log_path),
|
||||
level="WARNING",
|
||||
rotation="10 MB",
|
||||
compression="zip",
|
||||
enqueue=True,
|
||||
filter=lambda record: file_exception_filter(record, ignored_exceptions_tuple),
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
)
|
||||
|
||||
logger.info("Loguru Logger 初始化完成!")
|
||||
logger.info(f"控制台日志级别: {console_log_level}")
|
||||
302
konabot/common/longtask.py
Normal file
@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
import datetime
|
||||
import json
|
||||
from typing import Annotated, Any, Callable, Coroutine, cast
|
||||
import asyncio as asynkio
|
||||
import uuid
|
||||
|
||||
from loguru import logger
|
||||
import nonebot
|
||||
from nonebot.params import Depends
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters import Bot as BaseBot
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBBot
|
||||
from nonebot.adapters.onebot.v11 import GroupMessageEvent as OBGroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11 import PrivateMessageEvent as OBPrivateMessageEvent
|
||||
from nonebot.adapters.console import Bot as ConsoleBot
|
||||
from nonebot.adapters.console import MessageEvent as ConsoleMessageEvent
|
||||
from nonebot.adapters.discord import MessageEvent as DCMessageEvent
|
||||
from nonebot.adapters.discord import Bot as DCBot
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from .path import DATA_PATH
|
||||
|
||||
LONGTASK_DATA_DIR = DATA_PATH / "longtasks.json"
|
||||
QQ_PRIVATE_CHAT_CHANNEL_PREFIX = "_CHANNEL_QQ_PRIVATE_"
|
||||
|
||||
|
||||
class LongTaskTarget(BaseModel):
|
||||
"""
|
||||
用于定义长期任务的目标沟通对象,一般通过 DepLongTaskTarget 依赖注入获取:
|
||||
|
||||
```python
|
||||
@cmd.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
platform: str
|
||||
"沟通对象所在的平台"
|
||||
|
||||
self_id: str
|
||||
"进行沟通的对象自己的 ID"
|
||||
|
||||
channel_id: str
|
||||
"沟通对象所在的群或者 Discord Channel。若为空则代表是私聊"
|
||||
|
||||
target_id: str
|
||||
"沟通对象的 ID"
|
||||
|
||||
@property
|
||||
def is_private_chat(self):
|
||||
return self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX)
|
||||
|
||||
async def send_message(self, msg: UniMessage | str, at: bool = True) -> bool:
|
||||
try:
|
||||
bot = nonebot.get_bot(self.self_id)
|
||||
except KeyError:
|
||||
logger.warning(f"试图访问了不存在的 Bot。ID={self.self_id}")
|
||||
return False
|
||||
|
||||
if isinstance(msg, str):
|
||||
msg = UniMessage.text(msg)
|
||||
|
||||
if self.platform == "qq":
|
||||
if not isinstance(bot, OBBot):
|
||||
logger.warning(
|
||||
f"编号对应的平台并非期望的平台 ID={self.self_id} PLATFORM={
|
||||
self.platform
|
||||
} BOT_CLASS={bot.__class__.__name__}"
|
||||
)
|
||||
return False
|
||||
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),
|
||||
message=cast(Any, await msg.export(bot)),
|
||||
auto_escape=False,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
if at:
|
||||
msg = UniMessage().at(self.target_id).text(" ") + msg
|
||||
await bot.send_group_msg(
|
||||
group_id=int(self.channel_id),
|
||||
message=cast(Any, await msg.export(bot)),
|
||||
auto_escape=False,
|
||||
)
|
||||
return True
|
||||
if self.platform == "console":
|
||||
if not isinstance(bot, ConsoleBot):
|
||||
logger.warning(
|
||||
f"编号对应的平台并非期望的平台 ID={self.self_id} PLATFORM={
|
||||
self.platform
|
||||
} BOT_CLASS={bot.__class__.__name__}"
|
||||
)
|
||||
return False
|
||||
await bot.send_message(self.channel_id, cast(Any, await msg.export()))
|
||||
return True
|
||||
if self.platform == "discord":
|
||||
if not isinstance(bot, DCBot):
|
||||
logger.warning(
|
||||
f"编号对应的平台并非期望的平台 ID={self.self_id} PLATFORM={
|
||||
self.platform
|
||||
} BOT_CLASS={bot.__class__.__name__}"
|
||||
)
|
||||
return False
|
||||
await bot.send_to(
|
||||
channel_id=int(self.channel_id),
|
||||
message=cast(
|
||||
Any, await (UniMessage().at(self.target_id) + msg).export()
|
||||
),
|
||||
tts=False,
|
||||
)
|
||||
return True
|
||||
logger.warning(f"没有一个平台是期望的平台 PLATFORM={self.platform}")
|
||||
return False
|
||||
|
||||
|
||||
class LongTask(BaseModel):
|
||||
uuid: str
|
||||
data_json: str
|
||||
target: LongTaskTarget
|
||||
callback: str
|
||||
deadline: datetime.datetime
|
||||
|
||||
_aio_task: asynkio.Task | None = None
|
||||
|
||||
async def run(self):
|
||||
now = datetime.datetime.now()
|
||||
if self.deadline < now:
|
||||
await self._run_task()
|
||||
return
|
||||
await asynkio.sleep((self.deadline - now).total_seconds())
|
||||
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):
|
||||
hdl = registered_long_task_handler.get(self.callback, None)
|
||||
if hdl is None:
|
||||
logger.warning(
|
||||
f"Callback {self.callback} 未曾被注册,但是被期待调用,已忽略"
|
||||
)
|
||||
async with longtask_data() as datafile:
|
||||
del datafile.to_handle[self.callback][self.uuid]
|
||||
datafile.unhandled.setdefault(self.callback, []).append(self)
|
||||
|
||||
return
|
||||
success = False
|
||||
try:
|
||||
await hdl(self)
|
||||
success = True
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
async with longtask_data() as datafile:
|
||||
del datafile.to_handle[self.callback][self.uuid]
|
||||
if not success:
|
||||
datafile.unhandled.setdefault(self.callback, []).append(self)
|
||||
logger.info(
|
||||
f"LongTask 执行失败 UUID={self.uuid} callback={self.callback}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"LongTask 工作完成 UUID={self.uuid} callback={self.callback}"
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
self._aio_task = None
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return json.loads(self.data_json)
|
||||
|
||||
async def start(self):
|
||||
self._aio_task = asynkio.Task(self.run())
|
||||
self._aio_task.add_done_callback(lambda _: self.clean())
|
||||
|
||||
|
||||
class LongTaskModuleData(BaseModel):
|
||||
to_handle: dict[str, dict[str, LongTask]]
|
||||
unhandled: dict[str, list[LongTask]]
|
||||
|
||||
|
||||
async def get_long_task_target(event: BaseEvent, bot: BaseBot) -> LongTaskTarget | None:
|
||||
if isinstance(event, OBGroupMessageEvent):
|
||||
return LongTaskTarget(
|
||||
platform="qq",
|
||||
self_id=str(event.self_id),
|
||||
channel_id=str(event.group_id),
|
||||
target_id=str(event.user_id),
|
||||
)
|
||||
if isinstance(event, OBPrivateMessageEvent):
|
||||
return LongTaskTarget(
|
||||
platform="qq",
|
||||
self_id=str(event.self_id),
|
||||
channel_id=f"{QQ_PRIVATE_CHAT_CHANNEL_PREFIX}{event.self_id}",
|
||||
target_id=str(event.user_id),
|
||||
)
|
||||
if isinstance(event, ConsoleMessageEvent):
|
||||
return LongTaskTarget(
|
||||
platform="console",
|
||||
self_id=str(event.self_id),
|
||||
channel_id=str(event.channel.id),
|
||||
target_id=str(event.user.id),
|
||||
)
|
||||
if isinstance(event, DCMessageEvent):
|
||||
self_id = ""
|
||||
if isinstance(bot, DCBot):
|
||||
self_id = str(bot.self_id)
|
||||
return LongTaskTarget(
|
||||
platform="discord",
|
||||
self_id=self_id,
|
||||
channel_id=str(event.channel_id),
|
||||
target_id=str(event.user_id),
|
||||
)
|
||||
|
||||
|
||||
_TaskHandler = Callable[[LongTask], Coroutine[Any, Any, Any]]
|
||||
|
||||
|
||||
registered_long_task_handler: dict[str, _TaskHandler] = {}
|
||||
longtask_lock = asynkio.Lock()
|
||||
|
||||
|
||||
def handle_long_task(callback_id: str):
|
||||
def _decorator(func: _TaskHandler):
|
||||
assert callback_id not in registered_long_task_handler, (
|
||||
"有长任务的 ID 出现冲突,请换个名字!"
|
||||
)
|
||||
registered_long_task_handler[callback_id] = func
|
||||
return func
|
||||
|
||||
return _decorator
|
||||
|
||||
|
||||
def _load_longtask_data() -> LongTaskModuleData:
|
||||
try:
|
||||
txt = LONGTASK_DATA_DIR.read_text("utf-8")
|
||||
return LongTaskModuleData.model_validate_json(txt)
|
||||
except (FileNotFoundError, ValidationError) as e:
|
||||
logger.info(f"取得 LongTask 数据时出现问题:{e}")
|
||||
return LongTaskModuleData(
|
||||
to_handle={},
|
||||
unhandled={},
|
||||
)
|
||||
|
||||
|
||||
def _save_longtask_data(data: LongTaskModuleData):
|
||||
LONGTASK_DATA_DIR.write_text(data.model_dump_json(), "utf-8")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def longtask_data():
|
||||
async with longtask_lock:
|
||||
data = _load_longtask_data()
|
||||
yield data
|
||||
_save_longtask_data(data)
|
||||
|
||||
|
||||
async def create_longtask(
|
||||
handler: str,
|
||||
data: dict[str, Any],
|
||||
target: LongTaskTarget,
|
||||
deadline: datetime.datetime,
|
||||
):
|
||||
task = LongTask(
|
||||
uuid=str(uuid.uuid4()),
|
||||
data_json=json.dumps(data),
|
||||
target=target,
|
||||
callback=handler,
|
||||
deadline=deadline,
|
||||
)
|
||||
|
||||
logger.info(f"创建了新的 LongTask UUID={task.uuid} CALLBACK={task.callback}")
|
||||
await task.start()
|
||||
|
||||
async with longtask_data() as d:
|
||||
d.to_handle.setdefault(handler, {})[task.uuid] = task
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def init_longtask():
|
||||
counter = 0
|
||||
req: set[str] = set()
|
||||
|
||||
async with longtask_data() as data:
|
||||
for v in data.to_handle.values():
|
||||
for t in v.values():
|
||||
await t.start()
|
||||
counter += 1
|
||||
req.add(t.callback)
|
||||
|
||||
logger.info(f"LongTask 启动了任务 数量={counter} 期望的门类=[{','.join(req)}]")
|
||||
|
||||
|
||||
DepLongTaskTarget = Annotated[LongTaskTarget, Depends(get_long_task_target)]
|
||||
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
|
||||
183
konabot/common/nb/extract_image.py
Normal file
@ -0,0 +1,183 @@
|
||||
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.discord import MessageEvent as DiscordMessageEvent
|
||||
from nonebot.adapters.discord.config import Config as DiscordConfig
|
||||
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 pydantic import BaseModel
|
||||
from returns.result import Failure, Result, Success
|
||||
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
|
||||
|
||||
discordConfig = nonebot.get_plugin_config(DiscordConfig)
|
||||
|
||||
|
||||
class ExtractImageConfig(BaseModel):
|
||||
module_extract_image_no_download: bool = False
|
||||
"要不要算了,不下载了,直接爆炸算了,适用于一些比较奇怪的网络环境,无法从协议端下载文件"
|
||||
|
||||
|
||||
module_config = nonebot.get_plugin_config(ExtractImageConfig)
|
||||
|
||||
|
||||
async def download_image_bytes(url: str, proxy: str | None = None) -> Result[bytes, str]:
|
||||
# if "/matcha/cache/" in url:
|
||||
# url = url.replace('127.0.0.1', '10.126.126.101')
|
||||
if module_config.module_extract_image_no_download:
|
||||
return Success((ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes())
|
||||
logger.debug(f"开始从 {url} 下载图片")
|
||||
async with httpx.AsyncClient(proxy=proxy) 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)
|
||||
|
||||
if isinstance(evt, DiscordMessageEvent):
|
||||
logger.debug('获取图片的路径方式走 Discord')
|
||||
for a in evt.attachments:
|
||||
if "image/" not in a.content_type:
|
||||
continue
|
||||
url = a.proxy_url
|
||||
return (await download_image_bytes(url, discordConfig.discord_proxy)).bind(bytes_to_pil)
|
||||
|
||||
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
|
||||
33
konabot/common/nb/qq_broadcast.py
Normal file
@ -0,0 +1,33 @@
|
||||
from typing import Any, cast
|
||||
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBBot
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
|
||||
async def qq_broadcast(groups: list[str], msg: UniMessage[Any] | str):
|
||||
if isinstance(msg, str):
|
||||
msg = UniMessage.text(msg)
|
||||
bots: dict[str, OBBot] = {}
|
||||
|
||||
# group_id -> bot_id
|
||||
availabilities: dict[str, str] = {}
|
||||
|
||||
for bot_id, bot in nonebot.get_bots().items():
|
||||
if not isinstance(bot, OBBot):
|
||||
continue
|
||||
bots[bot_id] = bot
|
||||
gl = await bot.get_group_list()
|
||||
for g in gl:
|
||||
gid = str(g.get("group_id", -1))
|
||||
if gid in groups:
|
||||
availabilities[gid] = bot_id
|
||||
|
||||
for group in groups:
|
||||
if group in availabilities:
|
||||
bot = bots[availabilities[group]]
|
||||
await bot.send_group_msg(
|
||||
group_id=int(group),
|
||||
message=cast(Any, await msg.export(bot)),
|
||||
auto_escape=False,
|
||||
)
|
||||
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))
|
||||
34
konabot/common/nb/wzq_conflict.py
Normal file
@ -0,0 +1,34 @@
|
||||
from typing import cast
|
||||
from nonebot import get_bot, get_plugin_config, logger
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11.bot import Bot as OnebotBot
|
||||
from nonebot.rule import Rule
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class WZQConflictConfig(BaseModel):
|
||||
wzq_bot_qq: int = 0
|
||||
|
||||
config = get_plugin_config(WZQConflictConfig)
|
||||
|
||||
|
||||
async def no_wzqbot(evt: BaseEvent):
|
||||
if config.wzq_bot_qq <= 0:
|
||||
return True
|
||||
if not isinstance(evt, GroupMessageEvent):
|
||||
return True
|
||||
gid = evt.group_id
|
||||
sid = evt.self_id
|
||||
bot = cast(OnebotBot, get_bot(str(sid)))
|
||||
|
||||
members = await bot.get_group_member_list(group_id=gid)
|
||||
|
||||
members = set((m.get("user_id", -1) for m in members))
|
||||
if config.wzq_bot_qq in members:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
no_wzqbot_rule = Rule(no_wzqbot)
|
||||
|
||||
76
konabot/common/pager.py
Normal file
@ -0,0 +1,76 @@
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import Any, Callable
|
||||
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
|
||||
@dataclass
|
||||
class PagerQuery:
|
||||
page_index: int
|
||||
page_size: int
|
||||
|
||||
def apply[T](self, ls: list[T]) -> "PagerResult[T]":
|
||||
if self.page_size <= 0:
|
||||
return PagerResult(
|
||||
success=False,
|
||||
message="每页元素数量应该大于 0",
|
||||
data=[],
|
||||
page_count=-1,
|
||||
query=self,
|
||||
)
|
||||
page_count = ceil(len(ls) / self.page_size)
|
||||
if self.page_index <= 0 or self.page_size <= 0:
|
||||
return PagerResult(
|
||||
success=False,
|
||||
message="页数必须大于 0",
|
||||
data=[],
|
||||
page_count=page_count,
|
||||
query=self,
|
||||
)
|
||||
data = ls[(self.page_index - 1) * self.page_size: self.page_index * self.page_size]
|
||||
if len(data) > 0:
|
||||
return PagerResult(
|
||||
success=True,
|
||||
message="",
|
||||
data=data,
|
||||
page_count=page_count,
|
||||
query=self,
|
||||
)
|
||||
return PagerResult(
|
||||
success=False,
|
||||
message="指定的页数超过最大页数",
|
||||
data=data,
|
||||
page_count=page_count,
|
||||
query=self,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PagerResult[T]:
|
||||
data: list[T]
|
||||
success: bool
|
||||
message: str
|
||||
page_count: int
|
||||
query: PagerQuery
|
||||
|
||||
def to_unimessage(
|
||||
self,
|
||||
formatter: Callable[[T], str | UniMessage[Any]] = str,
|
||||
title: str = '查询结果',
|
||||
list_indicator: str = '- ',
|
||||
) -> UniMessage[Any]:
|
||||
msg = UniMessage.text(f'===== {title} =====\n\n')
|
||||
|
||||
if not self.success:
|
||||
msg = msg.text(f'⚠️ {self.message}\n')
|
||||
else:
|
||||
for obj in self.data:
|
||||
msg = msg.text(list_indicator)
|
||||
msg += formatter(obj)
|
||||
msg += '\n'
|
||||
|
||||
msg = msg.text(f'\n===== 第 {self.query.page_index} 页,共 {self.page_count} 页 =====')
|
||||
return msg
|
||||
|
||||
|
||||
@ -2,3 +2,23 @@ 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
|
||||
DATA_PATH = SRC_PATH.parent / "data"
|
||||
LOG_PATH = DATA_PATH / "logs"
|
||||
CONFIG_PATH = DATA_PATH / "config"
|
||||
|
||||
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"
|
||||
|
||||
if not DATA_PATH.exists():
|
||||
DATA_PATH.mkdir()
|
||||
|
||||
if not LOG_PATH.exists():
|
||||
LOG_PATH.mkdir()
|
||||
|
||||
CONFIG_PATH.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
653
konabot/common/ptimeparse/__init__.py
Normal file
@ -0,0 +1,653 @@
|
||||
import re
|
||||
import datetime
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
|
||||
from .err import MultipleSpecificationException, TokenUnhandledException
|
||||
|
||||
|
||||
class Parser:
|
||||
def __init__(self, now: Optional[datetime.datetime] = None):
|
||||
self.now = now or datetime.datetime.now()
|
||||
|
||||
def digest_chinese_number(self, text: str) -> Tuple[str, int]:
|
||||
if not text:
|
||||
return text, 0
|
||||
# Handle "两" at start
|
||||
if text.startswith("两"):
|
||||
next_char = text[1] if len(text) > 1 else ''
|
||||
if not next_char or next_char in "十百千万亿":
|
||||
return text[1:], 2
|
||||
s = "零一二三四五六七八九"
|
||||
digits = {c: i for i, c in enumerate(s)}
|
||||
i = 0
|
||||
while i < len(text) and text[i] in s + "十百千万亿":
|
||||
i += 1
|
||||
if i == 0:
|
||||
return text, 0
|
||||
num_str = text[:i]
|
||||
rest = text[i:]
|
||||
|
||||
def parse(s):
|
||||
if not s:
|
||||
return 0
|
||||
if s == "零":
|
||||
return 0
|
||||
if "亿" in s:
|
||||
a, b = s.split("亿", 1)
|
||||
return parse(a) * 100000000 + parse(b)
|
||||
if "万" in s:
|
||||
a, b = s.split("万", 1)
|
||||
return parse(a) * 10000 + parse(b)
|
||||
n = 0
|
||||
t = 0
|
||||
for c in s:
|
||||
if c == "零":
|
||||
continue
|
||||
if c in digits:
|
||||
t = digits[c]
|
||||
elif c == "十":
|
||||
if t == 0:
|
||||
t = 1
|
||||
n += t * 10
|
||||
t = 0
|
||||
elif c == "百":
|
||||
if t == 0:
|
||||
t = 1
|
||||
n += t * 100
|
||||
t = 0
|
||||
elif c == "千":
|
||||
if t == 0:
|
||||
t = 1
|
||||
n += t * 1000
|
||||
t = 0
|
||||
n += t
|
||||
return n
|
||||
|
||||
return rest, parse(num_str)
|
||||
|
||||
def parse(self, text: str) -> datetime.datetime:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise TokenUnhandledException("Empty input")
|
||||
|
||||
ctx = {
|
||||
"date": None,
|
||||
"time": None,
|
||||
"relative_delta": None,
|
||||
"am_pm": None,
|
||||
"period_word": None,
|
||||
"has_time": False,
|
||||
"has_date": False,
|
||||
"ambiguous_hour": False,
|
||||
"is_24hour": False,
|
||||
"has_relative_date": False,
|
||||
}
|
||||
|
||||
rest = self._parse_all(text, ctx)
|
||||
if rest.strip():
|
||||
raise TokenUnhandledException(f"Unparsed tokens: {rest.strip()}")
|
||||
|
||||
return self._apply_context(ctx)
|
||||
|
||||
def _parse_all(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||
rest = text.lstrip()
|
||||
while True:
|
||||
for parser in [
|
||||
self._parse_absolute_date,
|
||||
self._parse_relative_date,
|
||||
self._parse_relative_time,
|
||||
self._parse_period,
|
||||
self._parse_time,
|
||||
]:
|
||||
new_rest = parser(rest, ctx)
|
||||
if new_rest != rest:
|
||||
rest = new_rest.lstrip()
|
||||
break
|
||||
else:
|
||||
break
|
||||
return rest
|
||||
|
||||
def _add_delta(self, ctx, delta):
|
||||
if ctx["relative_delta"] is None:
|
||||
ctx["relative_delta"] = delta
|
||||
else:
|
||||
ctx["relative_delta"] += delta
|
||||
|
||||
def _parse_absolute_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||
text = text.lstrip()
|
||||
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{2})", text)
|
||||
if m:
|
||||
y, mth, d, h, minute = map(int, m.groups())
|
||||
ctx["date"] = datetime.date(y, mth, d)
|
||||
ctx["time"] = datetime.time(h, minute)
|
||||
ctx["has_date"] = True
|
||||
ctx["has_time"] = True
|
||||
ctx["is_24hour"] = True
|
||||
return text[m.end():]
|
||||
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})", text)
|
||||
if m:
|
||||
y, mth, d = map(int, m.groups())
|
||||
ctx["date"] = datetime.date(y, mth, d)
|
||||
ctx["has_date"] = True
|
||||
return text[m.end():]
|
||||
m = re.match(r"^(\d{4})/(\d{1,2})/(\d{1,2})", text)
|
||||
if m:
|
||||
y, mth, d = map(int, m.groups())
|
||||
ctx["date"] = datetime.date(y, mth, d)
|
||||
ctx["has_date"] = True
|
||||
return text[m.end():]
|
||||
m = re.match(r"^(\d{4})年(\d{1,2})月(\d{1,2})[日号]", text)
|
||||
if m:
|
||||
y, mth, d = map(int, m.groups())
|
||||
ctx["date"] = datetime.date(y, mth, d)
|
||||
ctx["has_date"] = True
|
||||
return text[m.end():]
|
||||
m = re.match(r"^(\d{1,2})月(\d{1,2})[日号]", text)
|
||||
if m:
|
||||
mth, d = map(int, m.groups())
|
||||
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||
ctx["has_date"] = True
|
||||
return text[m.end():]
|
||||
m = re.match(r"^(.{1,3})月(.{1,3})[日号]", text)
|
||||
if m:
|
||||
m_str, d_str = m.groups()
|
||||
_, mth = self.digest_chinese_number(m_str)
|
||||
_, d = self.digest_chinese_number(d_str)
|
||||
if mth == 0:
|
||||
mth = 1
|
||||
if d == 0:
|
||||
d = 1
|
||||
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||
ctx["has_date"] = True
|
||||
return text[m.end():]
|
||||
return text
|
||||
|
||||
def _parse_relative_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||
text = text.lstrip()
|
||||
|
||||
# Handle "今天", "今晚", "今早", etc.
|
||||
today_variants = [
|
||||
("今晚上", "PM"),
|
||||
("今晚", "PM"),
|
||||
("今早", "AM"),
|
||||
("今天早上", "AM"),
|
||||
("今天早晨", "AM"),
|
||||
("今天上午", "AM"),
|
||||
("今天下午", "PM"),
|
||||
("今天晚上", "PM"),
|
||||
("今天", None),
|
||||
]
|
||||
for variant, period in today_variants:
|
||||
if text.startswith(variant):
|
||||
self._add_delta(ctx, datetime.timedelta(days=0))
|
||||
ctx["has_relative_date"] = True
|
||||
rest = text[len(variant):]
|
||||
if period is not None and ctx["am_pm"] is None:
|
||||
ctx["am_pm"] = period
|
||||
ctx["period_word"] = variant
|
||||
return rest
|
||||
|
||||
mapping = {
|
||||
"明天": 1,
|
||||
"后天": 2,
|
||||
"大后天": 3,
|
||||
"昨天": -1,
|
||||
"前天": -2,
|
||||
"大前天": -3,
|
||||
}
|
||||
for word, days in mapping.items():
|
||||
if text.startswith(word):
|
||||
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||
ctx["has_relative_date"] = True
|
||||
return text[len(word):]
|
||||
m = re.match(r"^(\d+|[零一二三四五六七八九十两]+)天(后|前|以后|之后)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
n = int(num_str)
|
||||
else:
|
||||
_, n = self.digest_chinese_number(num_str)
|
||||
days = n if direction in ("后", "以后", "之后") else -n
|
||||
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||
ctx["has_relative_date"] = True
|
||||
return text[m.end():]
|
||||
m = re.match(r"^(本|上|下)周([一二三四五六日])", text)
|
||||
if m:
|
||||
scope, day = m.groups()
|
||||
weekday_map = {"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6}
|
||||
target = weekday_map[day]
|
||||
current = self.now.weekday()
|
||||
if scope == "本":
|
||||
delta = target - current
|
||||
elif scope == "上":
|
||||
delta = target - current - 7
|
||||
else:
|
||||
delta = target - current + 7
|
||||
self._add_delta(ctx, datetime.timedelta(days=delta))
|
||||
ctx["has_relative_date"] = True
|
||||
return text[m.end():]
|
||||
return text
|
||||
|
||||
def _parse_period(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||
text = text.lstrip()
|
||||
period_mapping = {
|
||||
"上午": "AM",
|
||||
"早晨": "AM",
|
||||
"早上": "AM",
|
||||
"早": "AM",
|
||||
"中午": "PM",
|
||||
"下午": "PM",
|
||||
"晚上": "PM",
|
||||
"晚": "PM",
|
||||
"凌晨": "AM",
|
||||
}
|
||||
for word, tag in period_mapping.items():
|
||||
if text.startswith(word):
|
||||
if ctx["am_pm"] is not None:
|
||||
raise MultipleSpecificationException("Multiple periods")
|
||||
ctx["am_pm"] = tag
|
||||
ctx["period_word"] = word
|
||||
return text[len(word):]
|
||||
return text
|
||||
|
||||
def _parse_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||
if ctx["has_time"]:
|
||||
return text
|
||||
text = text.lstrip()
|
||||
|
||||
# 1. H:MM pattern
|
||||
m = re.match(r"^(\d{1,2}):(\d{2})", text)
|
||||
if m:
|
||||
h, minute = int(m.group(1)), int(m.group(2))
|
||||
if 0 <= h <= 23 and 0 <= minute <= 59:
|
||||
ctx["time"] = datetime.time(h, minute)
|
||||
ctx["has_time"] = True
|
||||
ctx["ambiguous_hour"] = 1 <= h <= 12
|
||||
ctx["is_24hour"] = h > 12 or h == 0
|
||||
return text[m.end():]
|
||||
|
||||
# 2. Parse hour part
|
||||
hour = None
|
||||
rest_after_hour = text
|
||||
is_24hour_format = False
|
||||
|
||||
# Try Chinese number + 点/时
|
||||
temp_rest, num = self.digest_chinese_number(text)
|
||||
if num >= 0:
|
||||
temp_rest_stripped = temp_rest.lstrip()
|
||||
if temp_rest_stripped.startswith("点"):
|
||||
hour = num
|
||||
is_24hour_format = False
|
||||
rest_after_hour = temp_rest_stripped[1:]
|
||||
elif temp_rest_stripped.startswith("时"):
|
||||
hour = num
|
||||
is_24hour_format = True
|
||||
rest_after_hour = temp_rest_stripped[1:]
|
||||
|
||||
if hour is None:
|
||||
m = re.match(r"^(\d{1,2})\s*([点时])", text)
|
||||
if m:
|
||||
hour = int(m.group(1))
|
||||
is_24hour_format = m.group(2) == "时"
|
||||
rest_after_hour = text[m.end():]
|
||||
|
||||
if hour is None:
|
||||
if ctx.get("am_pm") is not None:
|
||||
temp_rest, num = self.digest_chinese_number(text)
|
||||
if 0 <= num <= 23:
|
||||
hour = num
|
||||
is_24hour_format = False
|
||||
rest_after_hour = temp_rest.lstrip()
|
||||
else:
|
||||
m = re.match(r"^(\d{1,2})", text)
|
||||
if m:
|
||||
h_val = int(m.group(1))
|
||||
if 0 <= h_val <= 23:
|
||||
hour = h_val
|
||||
is_24hour_format = False
|
||||
rest_after_hour = text[m.end():].lstrip()
|
||||
|
||||
if hour is None:
|
||||
return text
|
||||
|
||||
if not (0 <= hour <= 23):
|
||||
return text
|
||||
|
||||
# Parse minutes
|
||||
rest = rest_after_hour.lstrip()
|
||||
minute = 0
|
||||
minute_spec_count = 0
|
||||
|
||||
if rest.startswith("钟"):
|
||||
rest = rest[1:].lstrip()
|
||||
|
||||
has_zheng = False
|
||||
if rest.startswith("整"):
|
||||
has_zheng = True
|
||||
rest = rest[1:].lstrip()
|
||||
|
||||
if rest.startswith("半"):
|
||||
minute = 30
|
||||
minute_spec_count += 1
|
||||
rest = rest[1:].lstrip()
|
||||
if rest.startswith("钟"):
|
||||
rest = rest[1:].lstrip()
|
||||
if rest.startswith("整"):
|
||||
rest = rest[1:].lstrip()
|
||||
|
||||
if rest.startswith("一刻"):
|
||||
minute = 15
|
||||
minute_spec_count += 1
|
||||
rest = rest[2:].lstrip()
|
||||
if rest.startswith("钟"):
|
||||
rest = rest[1:].lstrip()
|
||||
|
||||
if rest.startswith("过一刻"):
|
||||
minute = 15
|
||||
minute_spec_count += 1
|
||||
rest = rest[3:].lstrip()
|
||||
if rest.startswith("钟"):
|
||||
rest = rest[1:].lstrip()
|
||||
|
||||
m = re.match(r"^(\d+|[零一二三四五六七八九十]+)分", rest)
|
||||
if m:
|
||||
minute_spec_count += 1
|
||||
m_str = m.group(1)
|
||||
if m_str.isdigit():
|
||||
minute = int(m_str)
|
||||
else:
|
||||
_, minute = self.digest_chinese_number(m_str)
|
||||
rest = rest[m.end():].lstrip()
|
||||
|
||||
if minute_spec_count == 0:
|
||||
temp_rest, num = self.digest_chinese_number(rest)
|
||||
if num > 0 and num <= 59:
|
||||
minute = num
|
||||
minute_spec_count += 1
|
||||
rest = temp_rest.lstrip()
|
||||
else:
|
||||
m = re.match(r"^(\d{1,2})", rest)
|
||||
if m:
|
||||
m_val = int(m.group(1))
|
||||
if 0 <= m_val <= 59:
|
||||
minute = m_val
|
||||
minute_spec_count += 1
|
||||
rest = rest[m.end():].lstrip()
|
||||
|
||||
if has_zheng and minute_spec_count == 0:
|
||||
minute_spec_count = 1
|
||||
|
||||
if minute_spec_count > 1:
|
||||
raise MultipleSpecificationException("Multiple minute specifications")
|
||||
|
||||
if not (0 <= minute <= 59):
|
||||
return text
|
||||
|
||||
# Hours 13-23 are always 24-hour, even with "点"
|
||||
if hour >= 13:
|
||||
is_24hour_format = True
|
||||
|
||||
ctx["time"] = datetime.time(hour, minute)
|
||||
ctx["has_time"] = True
|
||||
ctx["ambiguous_hour"] = 1 <= hour <= 12 and not is_24hour_format
|
||||
ctx["is_24hour"] = is_24hour_format
|
||||
|
||||
return rest
|
||||
|
||||
def _parse_relative_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||
text = text.lstrip()
|
||||
|
||||
# 半小时
|
||||
m = re.match(r"^(半)(?:个)?小时?(后|前|以后|之后)", text)
|
||||
if m:
|
||||
direction = m.group(2)
|
||||
hours = 0.5
|
||||
delta = datetime.timedelta(
|
||||
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
# X个半
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)个半(?:小时?)?(后|前|以后|之后)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
base_hours = int(num_str)
|
||||
else:
|
||||
_, base_hours = self.digest_chinese_number(num_str)
|
||||
if base_hours == 0 and num_str != "零":
|
||||
return text
|
||||
if base_hours <= 0:
|
||||
return text
|
||||
hours = base_hours + 0.5
|
||||
delta = datetime.timedelta(
|
||||
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
# 一个半
|
||||
m = re.match(r"^(一个半)小时?(后|前|以后|之后)", text)
|
||||
if m:
|
||||
direction = m.group(2)
|
||||
hours = 1.5
|
||||
delta = datetime.timedelta(
|
||||
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
# X小时
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时?(后|前|以后|之后)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
hours = int(num_str)
|
||||
else:
|
||||
_, hours = self.digest_chinese_number(num_str)
|
||||
if hours == 0 and num_str != "零":
|
||||
return text
|
||||
if hours <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时(后|前)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
hours = int(num_str)
|
||||
else:
|
||||
_, hours = self.digest_chinese_number(num_str)
|
||||
if hours == 0 and num_str != "零":
|
||||
return text
|
||||
if hours <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
hours=hours if direction == "后" else -hours
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
# X分钟
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前|以后|之后)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
minutes = int(num_str)
|
||||
else:
|
||||
_, minutes = self.digest_chinese_number(num_str)
|
||||
if minutes == 0 and num_str != "零":
|
||||
return text
|
||||
if minutes <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前|以后|之后)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
minutes = int(num_str)
|
||||
else:
|
||||
_, minutes = self.digest_chinese_number(num_str)
|
||||
if minutes == 0 and num_str != "零":
|
||||
return text
|
||||
if minutes <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
minutes = int(num_str)
|
||||
else:
|
||||
_, minutes = self.digest_chinese_number(num_str)
|
||||
if minutes == 0 and num_str != "零":
|
||||
return text
|
||||
if minutes <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
minutes=minutes if direction == "后" else -minutes
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
minutes = int(num_str)
|
||||
else:
|
||||
_, minutes = self.digest_chinese_number(num_str)
|
||||
if minutes == 0 and num_str != "零":
|
||||
return text
|
||||
if minutes <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
minutes=minutes if direction == "后" else -minutes
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
# === 秒级支持 ===
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前|以后|之后)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
seconds = int(num_str)
|
||||
else:
|
||||
_, seconds = self.digest_chinese_number(num_str)
|
||||
if seconds == 0 and num_str != "零":
|
||||
return text
|
||||
if seconds <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
seconds=seconds if direction in ("后", "以后", "之后") else -seconds
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前)", text)
|
||||
if m:
|
||||
num_str, direction = m.groups()
|
||||
if num_str.isdigit():
|
||||
seconds = int(num_str)
|
||||
else:
|
||||
_, seconds = self.digest_chinese_number(num_str)
|
||||
if seconds == 0 and num_str != "零":
|
||||
return text
|
||||
if seconds <= 0:
|
||||
return text
|
||||
delta = datetime.timedelta(
|
||||
seconds=seconds if direction == "后" else -seconds
|
||||
)
|
||||
self._add_delta(ctx, delta)
|
||||
return text[m.end():]
|
||||
|
||||
return text
|
||||
|
||||
def _apply_context(self, ctx: Dict[str, Any]) -> datetime.datetime:
|
||||
result = self.now
|
||||
has_date = ctx["has_date"]
|
||||
has_time = ctx["has_time"]
|
||||
has_delta = ctx["relative_delta"] is not None
|
||||
has_relative_date = ctx["has_relative_date"]
|
||||
|
||||
if has_delta:
|
||||
result = result + ctx["relative_delta"]
|
||||
|
||||
if has_date:
|
||||
result = result.replace(
|
||||
year=ctx["date"].year,
|
||||
month=ctx["date"].month,
|
||||
day=ctx["date"].day,
|
||||
)
|
||||
|
||||
if has_time:
|
||||
h = ctx["time"].hour
|
||||
m = ctx["time"].minute
|
||||
|
||||
if ctx["is_24hour"]:
|
||||
# "10 时" → 10:00, no conversion
|
||||
pass
|
||||
|
||||
elif ctx["am_pm"] == "AM":
|
||||
if h == 12:
|
||||
h = 0
|
||||
|
||||
elif ctx["am_pm"] == "PM":
|
||||
if h == 12:
|
||||
if ctx.get("period_word") in ("晚上", "晚"):
|
||||
h = 0
|
||||
result += datetime.timedelta(days=1)
|
||||
else:
|
||||
h = 12
|
||||
elif 1 <= h <= 11:
|
||||
h += 12
|
||||
|
||||
else:
|
||||
# No period and not 24-hour (i.e., "点" format)
|
||||
if ctx["has_relative_date"]:
|
||||
# "明天五点" → 05:00 AM
|
||||
if h == 12:
|
||||
h = 0
|
||||
# keep h as AM hour (1-11 unchanged)
|
||||
else:
|
||||
# Infer from current time
|
||||
am_hour = 0 if h == 12 else h
|
||||
candidate_am = result.replace(hour=am_hour, minute=m, second=0, microsecond=0)
|
||||
if candidate_am < self.now:
|
||||
# AM time is in the past, so use PM
|
||||
if h == 12:
|
||||
h = 12
|
||||
else:
|
||||
h += 12
|
||||
# else: keep as AM (h unchanged)
|
||||
|
||||
if h > 23:
|
||||
h = h % 24
|
||||
|
||||
result = result.replace(hour=h, minute=m, second=0, microsecond=0)
|
||||
|
||||
else:
|
||||
if has_date or (has_relative_date and not has_time):
|
||||
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse(text: str) -> datetime.datetime:
|
||||
return Parser().parse(text)
|
||||
11
konabot/common/ptimeparse/err.py
Normal file
@ -0,0 +1,11 @@
|
||||
class PTimeParseException(Exception):
|
||||
...
|
||||
|
||||
class TokenUnhandledException(PTimeParseException):
|
||||
...
|
||||
|
||||
class MultipleSpecificationException(PTimeParseException):
|
||||
...
|
||||
|
||||
class OutOfRangeSpecificationException(PTimeParseException):
|
||||
...
|
||||
54
konabot/common/username.py
Normal file
@ -0,0 +1,54 @@
|
||||
import re
|
||||
import nonebot
|
||||
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBBot
|
||||
|
||||
|
||||
class UsernameManager:
|
||||
grouped_data: dict[int, dict[int, str]]
|
||||
individual_data: dict[int, str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.grouped_data = {}
|
||||
self.individual_data = {}
|
||||
|
||||
async def update(self):
|
||||
for bot in nonebot.get_bots().values():
|
||||
if isinstance(bot, OBBot):
|
||||
for user in await bot.get_friend_list():
|
||||
uid = user["user_id"]
|
||||
nickname = user["nickname"]
|
||||
self.individual_data[uid] = nickname
|
||||
for group in await bot.get_group_list():
|
||||
gid = group["group_id"]
|
||||
for member in await bot.get_group_member_list(group_id=gid):
|
||||
uid = member["user_id"]
|
||||
card = member.get("card", "")
|
||||
nickname = member.get("nickname", "")
|
||||
if card:
|
||||
self.grouped_data.setdefault(gid, {})[uid] = card
|
||||
if nickname:
|
||||
self.individual_data[uid] = nickname
|
||||
|
||||
def get(self, qqid: int, groupid: int | None = None) -> str:
|
||||
if groupid is not None and groupid in self.grouped_data:
|
||||
n = self.grouped_data[groupid].get(qqid)
|
||||
if n is not None:
|
||||
return n
|
||||
if qqid in self.individual_data:
|
||||
return self.individual_data[qqid]
|
||||
return str(qqid)
|
||||
|
||||
|
||||
manager = UsernameManager()
|
||||
|
||||
def get_username(qqid: int | str, group: int | str | None = None):
|
||||
if isinstance(group, str):
|
||||
group = None if not re.match(r"^\d+$", group) else int(group)
|
||||
if isinstance(qqid, str):
|
||||
if re.match(r"^\d+$", qqid):
|
||||
qqid = int(qqid)
|
||||
else:
|
||||
return qqid
|
||||
return manager.get(qqid, group)
|
||||
|
||||
17
konabot/common/utils/to_async.py
Normal file
@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from typing import Awaitable, Callable, ParamSpec, TypeVar
|
||||
|
||||
|
||||
TA = ParamSpec("TA")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def make_async(func: Callable[TA, T]) -> Callable[TA, Awaitable[T]]:
|
||||
@functools.wraps(func, assigned=("__module__", "__name__", "__qualname__", "__doc__", "__annotations__"))
|
||||
async def wrapper(*args: TA.args, **kwargs: TA.kwargs):
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
9
konabot/common/web_render/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from .config import web_render_config
|
||||
from .core import WebRenderer as WebRenderer
|
||||
from .core import WebRendererInstance as WebRendererInstance
|
||||
|
||||
|
||||
def konaweb(sub_url: str):
|
||||
sub_url = '/' + sub_url.removeprefix('/')
|
||||
return web_render_config.module_web_render_weburl.removesuffix('/') + sub_url
|
||||
|
||||
20
konabot/common/web_render/config.py
Normal file
@ -0,0 +1,20 @@
|
||||
import nonebot
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Config(BaseModel):
|
||||
module_web_render_weburl: str = "localhost:5173"
|
||||
module_web_render_instance: str = ""
|
||||
module_web_render_playwright_ws: str = ""
|
||||
|
||||
def get_instance_baseurl(self):
|
||||
if self.module_web_render_instance:
|
||||
return self.module_web_render_instance.removesuffix('/')
|
||||
config = nonebot.get_driver().config
|
||||
ip = str(config.host)
|
||||
if ip == "0.0.0.0":
|
||||
ip = "127.0.0.1"
|
||||
port = config.port
|
||||
return f'http://{ip}:{port}'
|
||||
|
||||
web_render_config = nonebot.get_plugin_config(Config)
|
||||
403
konabot/common/web_render/core.py
Normal file
@ -0,0 +1,403 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import queue
|
||||
from typing import Any, Callable, Coroutine, Generic, TypeVar
|
||||
from loguru import logger
|
||||
from playwright.async_api import (
|
||||
Page,
|
||||
Playwright,
|
||||
async_playwright,
|
||||
Browser,
|
||||
BrowserContext,
|
||||
Error as PlaywrightError,
|
||||
)
|
||||
|
||||
from .config import web_render_config
|
||||
from playwright.async_api import ConsoleMessage, Page
|
||||
|
||||
T = TypeVar("T")
|
||||
TFunction = Callable[[T], Coroutine[Any, Any, Any]]
|
||||
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
||||
|
||||
|
||||
class WebRenderer:
|
||||
browser_pool: queue.Queue["WebRendererInstance"] = queue.Queue()
|
||||
context_pool: dict[int, BrowserContext] = {} # 长期挂载的浏览器上下文池
|
||||
page_pool: dict[str, Page] = {} # 长期挂载的页面池
|
||||
|
||||
@classmethod
|
||||
async def get_browser_instance(cls) -> "WebRendererInstance":
|
||||
if cls.browser_pool.empty():
|
||||
if web_render_config.module_web_render_playwright_ws:
|
||||
instance = await RemotePlaywrightInstance.create(
|
||||
web_render_config.module_web_render_playwright_ws
|
||||
)
|
||||
else:
|
||||
instance = await LocalPlaywrightInstance.create()
|
||||
cls.browser_pool.put(instance)
|
||||
instance = cls.browser_pool.get()
|
||||
cls.browser_pool.put(instance)
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
async def render(
|
||||
cls,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
访问指定URL并返回截图
|
||||
|
||||
:param url: 目标URL
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(
|
||||
f"Using WebRendererInstance {id(instance)} to render {url} targeting {target}"
|
||||
)
|
||||
return await instance.render(
|
||||
url, target, params=params, other_function=other_function, timeout=timeout
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def render_file(
|
||||
cls,
|
||||
file_path: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
访问指定本地文件URL并返回截图
|
||||
|
||||
:param file_path: 目标文件路径
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(
|
||||
f"Using WebRendererInstance {id(instance)} to render file {file_path} targeting {target}"
|
||||
)
|
||||
return await instance.render_file(
|
||||
file_path,
|
||||
target,
|
||||
params=params,
|
||||
other_function=other_function,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def render_with_persistent_page(
|
||||
cls,
|
||||
page_id: str,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
使用长期挂载的页面进行渲染
|
||||
|
||||
:param page_id: 页面唯一标识符
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(
|
||||
f"Using WebRendererInstance {id(instance)} to render with persistent page {page_id} targeting {target}"
|
||||
)
|
||||
return await instance.render_with_persistent_page(
|
||||
page_id,
|
||||
url,
|
||||
target,
|
||||
params=params,
|
||||
other_function=other_function,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_persistent_page(cls, page_id: str, url: str) -> Page:
|
||||
"""
|
||||
获取长期挂载的页面,如果不存在则创建一个新的页面并存储
|
||||
"""
|
||||
if page_id in cls.page_pool:
|
||||
return cls.page_pool[page_id]
|
||||
|
||||
async def on_console(msg: ConsoleMessage):
|
||||
logger.debug(f"WEB CONSOLE {msg.text}")
|
||||
|
||||
instance = await cls.get_browser_instance()
|
||||
if isinstance(instance, RemotePlaywrightInstance):
|
||||
context = await instance.browser.new_context()
|
||||
page = await context.new_page()
|
||||
await page.goto(url)
|
||||
cls.page_pool[page_id] = page
|
||||
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||
|
||||
page.on('console', on_console)
|
||||
|
||||
return page
|
||||
elif isinstance(instance, LocalPlaywrightInstance):
|
||||
context = await instance.browser.new_context()
|
||||
page = await context.new_page()
|
||||
await page.goto(url)
|
||||
cls.page_pool[page_id] = page
|
||||
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||
|
||||
page.on('console', on_console)
|
||||
|
||||
return page
|
||||
else:
|
||||
raise NotImplementedError("Unsupported WebRendererInstance type")
|
||||
|
||||
@classmethod
|
||||
async def close_persistent_page(cls, page_id: str) -> None:
|
||||
"""
|
||||
关闭并移除长期挂载的页面
|
||||
|
||||
:param page_id: 页面唯一标识符
|
||||
"""
|
||||
if page_id in cls.page_pool:
|
||||
page = cls.page_pool[page_id]
|
||||
await page.close()
|
||||
del cls.page_pool[page_id]
|
||||
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
|
||||
|
||||
|
||||
class WebRendererInstance(ABC, Generic[T]):
|
||||
@abstractmethod
|
||||
async def render(
|
||||
self,
|
||||
url: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: TFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes: ...
|
||||
|
||||
@abstractmethod
|
||||
async def render_file(
|
||||
self,
|
||||
file_path: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes: ...
|
||||
|
||||
@abstractmethod
|
||||
async def render_with_persistent_page(
|
||||
self,
|
||||
page_id: str,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
class PlaywrightInstance(WebRendererInstance[Page]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def browser(self) -> Browser: ...
|
||||
|
||||
async def render(
|
||||
self,
|
||||
url: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
访问指定URL并返回截图
|
||||
|
||||
:param url: 目标URL
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param index: 如果目标是一个列表,指定要截图的元素索引
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
async with self.lock:
|
||||
context = await self.browser.new_context()
|
||||
page = await context.new_page()
|
||||
screenshot = await self.inner_render(
|
||||
page, url, target, index, params or {}, other_function, timeout
|
||||
)
|
||||
await page.close()
|
||||
await context.close()
|
||||
return screenshot
|
||||
|
||||
async def render_file(
|
||||
self,
|
||||
file_path: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
file_path = "file:///" + str(file_path).replace("\\", "/")
|
||||
return await self.render(
|
||||
file_path, target, index, params or {}, other_function, timeout
|
||||
)
|
||||
|
||||
async def render_with_persistent_page(
|
||||
self,
|
||||
page_id: str,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
page = await WebRenderer.get_persistent_page(page_id, url)
|
||||
screenshot = await self.inner_render(
|
||||
page, url, target, 0, params, other_function, timeout
|
||||
)
|
||||
return screenshot
|
||||
|
||||
async def inner_render(
|
||||
self,
|
||||
page: Page,
|
||||
url: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
logger.debug(f"Navigating to {url} with timeout {timeout}")
|
||||
url_with_params = url + (
|
||||
"?" + "&".join(f"{k}={v}" for k, v in params.items()) if params else ""
|
||||
)
|
||||
await page.goto(url_with_params, timeout=timeout * 1000, wait_until="load")
|
||||
logger.debug("Page loaded successfully")
|
||||
# 等待目标元素出现
|
||||
await page.wait_for_selector(target, timeout=timeout * 1000)
|
||||
logger.debug(f"Target element '{target}' found, taking screenshot")
|
||||
if other_function:
|
||||
await other_function(page)
|
||||
elements = await page.query_selector_all(target)
|
||||
if not elements:
|
||||
logger.warning(f"Target element '{target}' not found on the page.")
|
||||
elements = await page.query_selector_all('body')
|
||||
if index >= len(elements):
|
||||
logger.warning(f"Index {index} out of range for elements matching '{target}'")
|
||||
index = 0
|
||||
element = elements[index]
|
||||
screenshot = await element.screenshot()
|
||||
logger.debug("Screenshot taken successfully")
|
||||
return screenshot
|
||||
|
||||
|
||||
class LocalPlaywrightInstance(PlaywrightInstance):
|
||||
def __init__(self):
|
||||
self._playwright: Playwright | None = None
|
||||
self._browser: Browser | None = None
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def playwright(self) -> Playwright:
|
||||
assert self._playwright is not None
|
||||
return self._playwright
|
||||
|
||||
@property
|
||||
def browser(self) -> Browser:
|
||||
assert self._browser is not None
|
||||
return self._browser
|
||||
|
||||
async def init(self):
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self.playwright.chromium.launch(headless=True)
|
||||
|
||||
@classmethod
|
||||
async def create(cls) -> "WebRendererInstance":
|
||||
instance = cls()
|
||||
await instance.init()
|
||||
return instance
|
||||
|
||||
async def close(self):
|
||||
await self.browser.close()
|
||||
await self.playwright.stop()
|
||||
|
||||
|
||||
class RemotePlaywrightInstance(PlaywrightInstance):
|
||||
def __init__(self, ws_endpoint: str) -> None:
|
||||
self._playwright: Playwright | None = None
|
||||
self._browser: Browser | None = None
|
||||
self._ws_endpoint = ws_endpoint
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def playwright(self) -> Playwright:
|
||||
assert self._playwright is not None, "Playwright must be initialized by calling init()."
|
||||
return self._playwright
|
||||
|
||||
@property
|
||||
def browser(self) -> Browser:
|
||||
assert self._browser is not None, "Browser must be connected by calling init()."
|
||||
return self._browser
|
||||
|
||||
async def init(self):
|
||||
logger.info(f"尝试连接远程 Playwright 服务器: {self._ws_endpoint}")
|
||||
self._playwright = await async_playwright().start()
|
||||
try:
|
||||
self._browser = await self.playwright.chromium.connect(
|
||||
self._ws_endpoint
|
||||
)
|
||||
logger.info("成功连接到远程 Playwright 服务器。")
|
||||
except PlaywrightError as e:
|
||||
await self.playwright.stop()
|
||||
raise ConnectionError(
|
||||
f"无法连接到远程 Playwright 服务器 ({self._ws_endpoint}):{e}"
|
||||
) from e
|
||||
|
||||
@classmethod
|
||||
async def create(cls, ws_endpoint: str) -> "RemotePlaywrightInstance":
|
||||
"""
|
||||
创建并初始化远程 Playwright 实例的工厂方法。
|
||||
"""
|
||||
instance = cls(ws_endpoint)
|
||||
await instance.init()
|
||||
return instance
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
断开与远程浏览器的连接并停止本地 Playwright 实例。
|
||||
"""
|
||||
if self._browser:
|
||||
await self.browser.close()
|
||||
if self._playwright:
|
||||
await self.playwright.stop()
|
||||
print("已断开远程连接,本地 Playwright 实例已停止。")
|
||||
|
||||
66
konabot/common/web_render/host_images.py
Normal file
@ -0,0 +1,66 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
import nanoid
|
||||
import nonebot
|
||||
|
||||
from nonebot.drivers.fastapi import Driver as FastAPIDriver
|
||||
|
||||
from .config import web_render_config
|
||||
|
||||
app = cast(FastAPIDriver, nonebot.get_driver()).asgi
|
||||
|
||||
hosted_tempdirs: dict[str, Path] = {}
|
||||
hosted_tempdirs_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TempDir:
|
||||
path: Path
|
||||
url_base: str
|
||||
|
||||
def url_of(self, file: Path):
|
||||
assert file.is_relative_to(self.path)
|
||||
relative_path = file.relative_to(self.path)
|
||||
url_path_segment = str(relative_path).replace("\\", "/")
|
||||
return f"{self.url_base}/{url_path_segment}"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def host_tempdir():
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
fp = Path(tempdir)
|
||||
nid = nanoid.generate(size=10)
|
||||
async with hosted_tempdirs_lock:
|
||||
hosted_tempdirs[nid] = fp
|
||||
yield TempDir(
|
||||
path=fp,
|
||||
url_base=f"{web_render_config.get_instance_baseurl()}/tempdir/{nid}",
|
||||
)
|
||||
async with hosted_tempdirs_lock:
|
||||
del hosted_tempdirs[nid]
|
||||
|
||||
|
||||
@app.get("/tempdir/{nid}/{file_path:path}")
|
||||
async def _(nid: str, file_path: str):
|
||||
async with hosted_tempdirs_lock:
|
||||
base_path = hosted_tempdirs.get(nid)
|
||||
if base_path is None:
|
||||
raise HTTPException(404)
|
||||
full_path = base_path / file_path
|
||||
try:
|
||||
if not full_path.resolve().is_relative_to(base_path.resolve()):
|
||||
raise HTTPException(status_code=403, detail="Access denied.")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=403, detail="Access denied.")
|
||||
if not full_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found.")
|
||||
|
||||
return FileResponse(full_path.resolve())
|
||||
|
||||
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 喵` 查看指令「喵」的使用说明
|
||||
……
|
||||
```
|
||||
11
konabot/docs/concepts/中间答案.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# 关于「中间答案」或者「提示」
|
||||
|
||||
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
|
||||
|
||||
- 纯文本的完整匹配:你设置的 pattern 如果和提交的答案完全相等,则会触发提示。
|
||||
- regex 匹配:你设置的 pattern 如果以斜杠(/)开头和结尾,就会检查提交的答案是否匹配正则表达式。注意 ^ 和 $ 符号的使用。
|
||||
- 例如:/^commit$/ 会匹配 commit,不会匹配 acommit、Commit 等。
|
||||
- 而如果是 /commit/,则会匹配 commit、acommit,而不会匹配 Commit。
|
||||
- 无法使用 Javascript 的字符串声明模式,例如,/case_insensitive/i 就不会被视作一个正则表达式。
|
||||
|
||||
一个提示是提示,还是中间答案,取决于它是否有 checkpoint 标记。如果有 checkpoint 标记,则会提示用户「你回答了一个中间答案」,并且这个中间答案的回答会在排行榜中显示。
|
||||
3
konabot/docs/concepts/罗文.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# 关于罗文和洛温
|
||||
|
||||
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。
|
||||
48
konabot/docs/lib/is_admin.txt
Normal file
@ -0,0 +1,48 @@
|
||||
# 指令介绍
|
||||
|
||||
`is_admin` - 用于判断当前事件是否来自管理员的内部权限校验函数
|
||||
|
||||
# 格式
|
||||
|
||||
```python
|
||||
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
|
||||
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
||||
5
konabot/docs/sys/konaph.txt
Normal file
@ -0,0 +1,5 @@
|
||||
# 指令介绍
|
||||
|
||||
`konaph` - KonaBot 的 PuzzleHunt 管理工具
|
||||
|
||||
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)
|
||||
1
konabot/docs/sys/out.txt
Normal file
@ -0,0 +1 @@
|
||||
MAN what can I say!
|
||||
83
konabot/docs/user/giftool.txt
Normal file
@ -0,0 +1,83 @@
|
||||
# giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
||||
|
||||
## 格式
|
||||
|
||||
```bash
|
||||
giftool [图片] [选项]
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- **回复一张 GIF 并发送:**
|
||||
|
||||
```bash
|
||||
giftool --ss 1.5 -t 2.0
|
||||
```
|
||||
|
||||
从 1.5 秒处开始,截取 2 秒长度的片段。
|
||||
|
||||
- ```bash
|
||||
giftool [图片] --ss 0:10 -to 0:15
|
||||
```
|
||||
|
||||
截取从 10 秒到 15 秒之间的片段(支持 `MM:SS` 或 `HH:MM:SS` 格式)。
|
||||
|
||||
- ```bash
|
||||
giftool [图片] --frames:v 10
|
||||
```
|
||||
|
||||
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
||||
|
||||
- ```bash
|
||||
giftool [图片] --ss 2 --frames:v 5
|
||||
```
|
||||
|
||||
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 图片(必需)
|
||||
|
||||
- 必须是 GIF 动图。
|
||||
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
||||
|
||||
### `--ss <时间戳>`(可选)
|
||||
|
||||
- 指定开始时间(单位:秒),可使用以下格式:
|
||||
- 纯数字(如 `1.5` 表示 1.5 秒)
|
||||
- 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
||||
- 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
||||
- 默认从开头开始(0 秒)。
|
||||
|
||||
### `-t <持续时间>`(可选)
|
||||
|
||||
- 指定截取的持续时间(单位:秒),格式同 `--ss`。
|
||||
- 与 `--ss` 配合使用:截取 `[ss, ss + t]` 区间。
|
||||
- **不能与 `--to` 同时使用。**
|
||||
|
||||
### `--to <时间戳>`(可选)
|
||||
|
||||
- 指定结束时间(单位:秒),格式同 `--ss`。
|
||||
- 与 `--ss` 配合使用:截取 `[ss, to]` 区间。
|
||||
- **不能与 `-t` 同时使用。**
|
||||
|
||||
### `--frames:v <帧数>`(可选)
|
||||
|
||||
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
||||
- 帧数必须为正整数(> 0)。
|
||||
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
||||
|
||||
### `--speed <速度>`(可选)
|
||||
|
||||
- 调整 GIF 图的速度。若为负数,则代表倒放。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 发送指令前,请确保:
|
||||
- 消息中附带一张 GIF 动图,**或**
|
||||
- 回复一条包含 GIF 动图的消息后再发送指令。
|
||||
2. 插件会自动:
|
||||
- 解析 GIF 的每一帧及其持续时间(duration)
|
||||
- 根据时间参数转换为帧索引进行裁剪
|
||||
- 如指定抽帧,则对裁剪后的片段均匀采样
|
||||
- 生成新的 GIF 并保持原始循环设置(`loop=0`)
|
||||
33
konabot/docs/user/man.txt
Normal file
@ -0,0 +1,33 @@
|
||||
# 指令介绍
|
||||
|
||||
`man` - 用于展示此方 BOT 使用手册的指令
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
man 文档类型
|
||||
man [文档类型] <指令>
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- ``man``
|
||||
查看所有有文档的指令清单
|
||||
|
||||
- ``man 3``
|
||||
列举所有可读文档的库函数清单
|
||||
|
||||
- ``man 喵``
|
||||
查看指令「喵」的使用说明
|
||||
|
||||
- ``man 8 out``
|
||||
查看管理员指令「out」的使用说明
|
||||
|
||||
## 文档类型
|
||||
|
||||
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为以下种类:
|
||||
|
||||
- **1** 用户态指令:用于日常使用的指令
|
||||
- **3** 库函数指令:用于 Bot 开发用的函数查询
|
||||
- **7** 概念指令:用于概念解释
|
||||
- **8** 系统指令:仅管理员可用
|
||||
16
konabot/docs/user/ntfy.txt
Normal file
@ -0,0 +1,16 @@
|
||||
## 指令介绍
|
||||
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的代办事项。
|
||||
|
||||
## 指令示例
|
||||
|
||||
- **`ntfy 创建`**
|
||||
创建一个随机的 ntfy 订阅主题来提醒代办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP,或者使用网页版 ntfy。
|
||||
|
||||
- **`ntfy 创建 kagami-notice`**
|
||||
创建一个名称包含 `kagami-notice` 的 ntfy 订阅主题。
|
||||
|
||||
- **`ntfy 删除`**
|
||||
清除配置,不再使用 ntfy 向你发送通知。
|
||||
|
||||
## 另见
|
||||
[`提醒我(1)`](#) [`查询提醒(1)`](#) [`删除提醒(1)`](#)
|
||||
39
konabot/docs/user/openssl.txt
Normal file
@ -0,0 +1,39 @@
|
||||
# 指令介绍
|
||||
|
||||
`openssl rand` — 用于生成指定长度的加密安全随机数据。
|
||||
|
||||
## 格式
|
||||
|
||||
```bash
|
||||
openssl rand <模式> <字节数>
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- ```bash
|
||||
openssl rand -hex 16
|
||||
```
|
||||
生成 16 字节的十六进制随机数。
|
||||
|
||||
- ```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
生成 32 字节并以 Base64 编码输出的随机数据。
|
||||
|
||||
## 说明
|
||||
|
||||
该指令使用 Python 的 `secrets` 模块生成加密安全的随机字节,并支持以以下格式输出:
|
||||
- 十六进制(`-hex`)
|
||||
- Base64 编码(`-base64`)
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 模式(mode)
|
||||
|
||||
- `-hex`:以十六进制字符串形式输出随机数据
|
||||
- `-base64`:以 Base64 编码字符串形式输出随机数据
|
||||
|
||||
### 字节数(num)
|
||||
|
||||
- 必须为正整数
|
||||
- 最大支持 256 字节
|
||||
55
konabot/docs/user/shadertool.txt
Normal file
@ -0,0 +1,55 @@
|
||||
# 指令介绍
|
||||
`shadertool` - 使用 SkSL(Skia Shader Language)代码实时渲染并生成 GIF 动画
|
||||
|
||||
## 格式
|
||||
```bash
|
||||
shadertool [选项] <SkSL 代码>
|
||||
```
|
||||
|
||||
## 示例
|
||||
```bash
|
||||
shadertool """
|
||||
uniform float u_time;
|
||||
uniform float2 u_resolution;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
return half4(
|
||||
1.0,
|
||||
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
||||
coord.x / u_resolution.x,
|
||||
1.0
|
||||
);
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
### SkSL 代码(必填)
|
||||
- **类型**:字符串(建议用英文双引号包裹)
|
||||
- **内容**:符合 SkSL 语法的片段着色器代码,必须包含 `main` 函数,并返回 `half4` 类型的颜色值。
|
||||
- **注意**:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
||||
|
||||
### `--width <整数>`(可选)
|
||||
- **默认值**:`320`
|
||||
- **作用**:输出 GIF 的宽度(像素),必须大于 0。
|
||||
|
||||
### `--height <整数>`(可选)
|
||||
- **默认值**:`180`
|
||||
- **作用**:输出 GIF 的高度(像素),必须大于 0。
|
||||
|
||||
### `--duration <浮点数>`(可选)
|
||||
- **默认值**:`1.0`
|
||||
- **作用**:动画总时长(秒),必须大于 0。
|
||||
- **限制**:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
||||
|
||||
### `--fps <浮点数>`(可选)
|
||||
- **默认值**:`15.0`
|
||||
- **作用**:每秒帧数,控制动画流畅度,必须大于 0。
|
||||
- **常见值**:
|
||||
- `10`:低配流畅
|
||||
- `15`:默认
|
||||
- `24` / `30`:电影/视频级流畅度
|
||||
|
||||
## 使用方式
|
||||
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
||||
72
konabot/docs/user/ytpgif.txt
Normal file
@ -0,0 +1,72 @@
|
||||
# `ytpgif` 指令说明
|
||||
|
||||
## 功能简介
|
||||
`ytpgif` 用于生成来回镜像翻转的仿 YTPMV(YouTube Poop Music Video)风格动图。
|
||||
|
||||
---
|
||||
|
||||
## 命令格式
|
||||
```bash
|
||||
ytpgif [倍速]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
- **默认倍速**
|
||||
```bash
|
||||
ytpgif
|
||||
```
|
||||
使用默认倍速(1.0)处理你发送或回复的图片,生成镜像动图。
|
||||
|
||||
- **指定倍速(较快)**
|
||||
```bash
|
||||
ytpgif 2.5
|
||||
```
|
||||
以 2.5 倍速处理图片,生成节奏更快的镜像动图。
|
||||
|
||||
- **指定倍速(较慢)**
|
||||
回复一张图片并发送:
|
||||
```bash
|
||||
ytpgif 0.5
|
||||
```
|
||||
以 0.5 倍速生成慢节奏的镜像动图。
|
||||
|
||||
---
|
||||
|
||||
## 参数说明
|
||||
|
||||
### `倍速`(可选)
|
||||
- **类型**:浮点数
|
||||
- **默认值**:`1.0`
|
||||
- **有效范围**:`0.1 ~ 20.0`
|
||||
|
||||
#### 作用:
|
||||
- **静态图**:控制“原图 ↔ 镜像”切换的速度(值越大,切换越快)。
|
||||
- **GIF 动图**:控制截取原始动图正向与反向片段的时长(值越大,截取的片段越长)。
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
在发送指令前,请确保满足以下任一条件:
|
||||
- 在消息中**直接附带一张图片**,或
|
||||
- **回复一条包含图片的消息**后再发送指令。
|
||||
|
||||
插件将自动执行以下操作:
|
||||
1. 下载并识别图片(支持静态图和 GIF 动图)。
|
||||
2. 自动缩放图像,**最大边长不超过 256 像素**(保持宽高比)。
|
||||
3. 根据图片类型处理:
|
||||
- **静态图** → 生成“原图 ↔ 镜像”循环动图。
|
||||
- **GIF 动图** → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图。
|
||||
4. **保留透明通道**(若原图含透明),否则转为 RGB 模式以避免颜色异常。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ 以下情况可能导致处理失败或效果不佳:
|
||||
- 图片过大、格式损坏或网络问题;
|
||||
- 动图帧数过多或单帧持续时间过短;
|
||||
- 输出 GIF 单段帧数超过 **500 帧**(系统将自动限制以防资源耗尽)。
|
||||
9
konabot/docs/user/删除提醒.txt
Normal file
@ -0,0 +1,9 @@
|
||||
## 指令介绍
|
||||
**删除提醒** - 删除在 [`查询提醒(1)`](查询提醒(1)) 中查到的提醒
|
||||
|
||||
## 指令示例
|
||||
`删除提醒 1`
|
||||
在查询提醒后,删除编号为 1 的提醒
|
||||
|
||||
## 另见
|
||||
[`提醒我(1)`](提醒我(1)) [`查询提醒(1)`](查询提醒(1)) [`ntfy(1)`](ntfy(1))
|
||||
24
konabot/docs/user/卵总展示.txt
Normal file
@ -0,0 +1,24 @@
|
||||
# 指令介绍
|
||||
|
||||
**卵总展示** - 让卵总举起你的图片
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
<引用图片> 卵总展示 [选项]
|
||||
卵总展示 [选项] <图片>
|
||||
```
|
||||
|
||||
## 选项
|
||||
|
||||
- `--whiteness <number>` **白度**
|
||||
将原图进行指数变换,以调整它的白的程度,默认为 `0.0`。
|
||||
|
||||
- `--black-level <number>` **黑色等级**
|
||||
将原图减淡,数值越大越淡,范围 `0.0–1.0`,默认为 `0.2`。
|
||||
|
||||
- `--opacity <number>` **不透明度**
|
||||
将你的图片叠放在图片上的不透明度,默认为 `0.8`。
|
||||
|
||||
- `--saturation <number>` **饱和度**
|
||||
调整原图的饱和度,应大于 `0.0`,默认为 `0.85`。
|
||||
16
konabot/docs/user/发起投票.txt
Normal file
@ -0,0 +1,16 @@
|
||||
### 指令介绍
|
||||
**发起投票** - 发起一个投票
|
||||
|
||||
### 格式
|
||||
```
|
||||
发起投票 <投票标题> <选项1> <选项2> ...
|
||||
```
|
||||
|
||||
### 示例
|
||||
`发起投票 这是一个投票 A B C`
|
||||
发起标题为“这是一个投票”,选项为“A”、“B”、“C”的投票。
|
||||
|
||||
### 说明
|
||||
- 投票的各个选项之间用空格分隔。
|
||||
- 选项数量必须为 **2 到 15 项**。
|
||||
- 投票的默认有效期为 **24 小时**。
|
||||
3
konabot/docs/user/喵.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# 指令介绍
|
||||
|
||||
喵 - 你发喵,此方就会回复喵
|
||||
16
konabot/docs/user/投票.txt
Normal file
@ -0,0 +1,16 @@
|
||||
## 指令介绍
|
||||
**投票** - 参与已发起的投票
|
||||
|
||||
## 格式
|
||||
```
|
||||
投票 <投票ID/标题> <选项文本>
|
||||
```
|
||||
|
||||
## 示例
|
||||
- `投票 1 A`
|
||||
在 ID 为 1 的投票中,投给 “A”
|
||||
- `投票 这是一个投票 B`
|
||||
在标题为 “这是一个投票” 的投票中,投给 “B”
|
||||
|
||||
## 说明
|
||||
目前不支持单人多投,每个人只能投一项。
|
||||
18
konabot/docs/user/提醒我.txt
Normal file
@ -0,0 +1,18 @@
|
||||
## 指令介绍
|
||||
|
||||
**提醒我** - 在指定的时间提醒人事项的工具
|
||||
|
||||
## 使用示例
|
||||
|
||||
- `下午五点提醒我吃饭`
|
||||
创建一个下午五点的提醒,提醒你吃饭
|
||||
|
||||
- `两分钟后提醒我睡觉`
|
||||
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
|
||||
|
||||
- `2026年4月25日20点整提醒我生日快乐`
|
||||
创建一个指定日期和时间的提醒
|
||||
|
||||
## 另见
|
||||
|
||||
[`查询提醒(1)`](查询提醒) [`删除提醒(1)`](删除提醒) [`ntfy(1)`](ntfy)
|
||||
13
konabot/docs/user/摇数字.txt
Normal file
@ -0,0 +1,13 @@
|
||||
## 指令介绍
|
||||
|
||||
**摇数字** - 生成一个随机数字并发送
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
摇数字
|
||||
```
|
||||
|
||||
随机生成一个 1-6 的数字。
|
||||
|
||||
> 该指令不接受任何参数,直接调用即可。
|
||||
33
konabot/docs/user/摇骰子.txt
Normal file
@ -0,0 +1,33 @@
|
||||
# 指令介绍
|
||||
|
||||
**摇骰子** - 用于生成随机数并以骰子图像形式展示的指令
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
摇骰子 [最小值] [最大值]
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- `摇骰子`
|
||||
随机生成一个 1–6 的数字,并显示对应的骰子图像
|
||||
- `摇骰子 10`
|
||||
生成 1 到 10 之间的随机整数
|
||||
- `摇骰子 0.5`
|
||||
生成 0 到 0.5 之间的随机小数
|
||||
- `摇骰子 -5 5`
|
||||
生成 -5 到 5 之间的随机数
|
||||
|
||||
## 说明
|
||||
|
||||
该指令支持以下几种调用方式:
|
||||
|
||||
- **不带参数**:使用默认范围(1–6)生成随机数
|
||||
- **仅指定一个参数 `f1`**:
|
||||
- 若 `f1 > 1`,则生成 `[1, f1]` 范围内的随机数
|
||||
- 若 `0 < f1 ≤ 1`,则生成 `[0, f1]` 范围内的随机数
|
||||
- 若 `f1 ≤ 0`,则生成 `[f1, 0]` 范围内的随机数
|
||||
- **指定两个参数 `f1` 和 `f2`**:生成 `[f1, f2]` 范围内的随机数(顺序无关,内部会自动处理大小)
|
||||
|
||||
返回结果将以骰子样式的图像形式展示生成的随机数值。
|
||||
22
konabot/docs/user/查看投票.txt
Normal file
@ -0,0 +1,22 @@
|
||||
# 指令介绍
|
||||
|
||||
**查看投票** - 查看已发起的投票
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
查看投票 <投票ID或标题>
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- `查看投票 1`
|
||||
查看 ID 为 1 的投票
|
||||
|
||||
- `查看投票 这是一个投票`
|
||||
查看标题为“这是一个投票”的投票
|
||||
|
||||
## 说明
|
||||
|
||||
- 投票进行中时,使用此命令可查看投票的各个选项;
|
||||
- 投票结束后,可查看各选项的最终票数。
|
||||
9
konabot/docs/user/查询提醒.txt
Normal file
@ -0,0 +1,9 @@
|
||||
# 指令介绍
|
||||
**查询提醒** - 查询已经创建的提醒
|
||||
|
||||
## 指令格式
|
||||
- `查询提醒`:查询提醒
|
||||
- `查询提醒 2`:查询第二页提醒
|
||||
|
||||
## 另见
|
||||
[提醒我(1)]() [删除提醒(1)]() [ntfy(1)]()
|
||||
17
konabot/docs/user/生成二维码.txt
Normal file
@ -0,0 +1,17 @@
|
||||
## 指令介绍
|
||||
|
||||
**生成二维码** - 将文本内容转换为二维码
|
||||
|
||||
### 格式
|
||||
|
||||
```
|
||||
生成二维码 <文本内容>
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
生成二维码 嗨嗨嗨
|
||||
```
|
||||
|
||||
生成扫描结果为“嗨嗨嗨”的二维码图片
|
||||
30
konabot/docs/user/订阅.txt
Normal file
@ -0,0 +1,30 @@
|
||||
# 指令介绍
|
||||
|
||||
**订阅** - 收听此方 BOT 的自动消息发送。
|
||||
|
||||
---
|
||||
|
||||
## 格式
|
||||
|
||||
- `订阅 <频道名称>`
|
||||
- `取消订阅 <频道名称>`
|
||||
- `查询订阅 [页码]`
|
||||
- `可用订阅 [页码]`
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
- **`订阅 此方谜题`**
|
||||
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
|
||||
- 如果你是私聊,则能够每天发送此方谜题到你的私聊;
|
||||
- 如果在群聊中使用该指令,则会每天发送题目到这个群里面。
|
||||
|
||||
- **`取消订阅 此方谜题`**
|
||||
取消订阅「此方谜题」频道。
|
||||
|
||||
- **`查询订阅`**
|
||||
查询当前聊天上下文订阅的所有频道。
|
||||
|
||||
- **`可用订阅 2`**
|
||||
查询所有可用的订阅的第二页。
|
||||
20
konabot/docs/user/雷达回波.txt
Normal file
@ -0,0 +1,20 @@
|
||||
# 指令介绍
|
||||
|
||||
**雷达回波** - 用于获取指定地区的天气雷达回波图像。
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
雷达回波 <地区>
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- `雷达回波 华南`:获取华南地区的天气雷达回波图
|
||||
- `雷达回波 全国`:获取全国的天气雷达回波图
|
||||
|
||||
## 说明
|
||||
|
||||
该指令通过查询中国气象局 [https://www.nmc.cn/publish/radar/chinaall.html](https://www.nmc.cn/publish/radar/chinaall.html),获取指定地区的实时天气雷达回波图像。
|
||||
|
||||
支持的地区有:**全国**、**华北**、**东北**、**华东**、**华中**、**华南**、**西南**、**西北**。
|
||||
7
konabot/docs/user/黑白.txt
Normal file
@ -0,0 +1,7 @@
|
||||
## 指令介绍
|
||||
|
||||
**黑白** - 将图片经过一个黑白滤镜的处理
|
||||
|
||||
## 示例
|
||||
|
||||
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
||||
162
konabot/plugins/air_conditioner/__init__.py
Normal file
@ -0,0 +1,162 @@
|
||||
from io import BytesIO
|
||||
from typing import Optional, Union
|
||||
import cv2
|
||||
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, AlconnaMatcher, Args, UniMessage, on_alconna
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
from konabot.common.web_render import WebRenderer
|
||||
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
|
||||
|
||||
import random
|
||||
import math
|
||||
|
||||
def get_ac(id: str) -> AirConditioner:
|
||||
ac = AirConditioner.air_conditioners.get(id)
|
||||
if ac is None:
|
||||
ac = AirConditioner(id)
|
||||
return ac
|
||||
|
||||
async def send_ac_image(event: type[AlconnaMatcher], ac: AirConditioner):
|
||||
if(ac.burnt == True):
|
||||
# 打开坏掉的空调图片
|
||||
with open(ASSETS_PATH / "img" / "ac" / "broken_ac.png", "rb") as f:
|
||||
# 将其转为 GIF 格式发送
|
||||
output = BytesIO()
|
||||
Image.open(f).save(output, format="GIF")
|
||||
output.seek(0)
|
||||
await event.send(await UniMessage().image(raw=output).export())
|
||||
return
|
||||
if(ac.frozen == True):
|
||||
# 打开坏掉的空调图片
|
||||
with open(ASSETS_PATH / "img" / "ac" / "frozen_ac.png", "rb") as f:
|
||||
# 将其转为 GIF 格式发送
|
||||
output = BytesIO()
|
||||
Image.open(f).save(output, format="GIF")
|
||||
output.seek(0)
|
||||
await event.send(await UniMessage().image(raw=output).export())
|
||||
return
|
||||
ac_image = await generate_ac_image(ac)
|
||||
await event.send(await UniMessage().image(raw=ac_image).export())
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"群空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"开空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
ac.on = True
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"关空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
ac.on = False
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调升温",
|
||||
Args["temp?", Optional[Union[int, float]]] # 可选参数,升温的度数,默认为1
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||
if temp <= 0:
|
||||
return
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||
await send_ac_image(evt, ac)
|
||||
return
|
||||
ac.temperature += temp
|
||||
if ac.temperature > 40:
|
||||
# 根据温度随机出是否爆炸,40度开始,呈指数增长
|
||||
possibility = -math.e ** ((40-ac.temperature) / 50) + 1
|
||||
if random.random() < possibility:
|
||||
# 打开爆炸图片
|
||||
with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f:
|
||||
output = BytesIO()
|
||||
# 爆炸抖动
|
||||
frames = wiggle_transform(np.array(Image.open(f)), intensity=5)
|
||||
pil_frames = [Image.fromarray(frame) for frame in frames]
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=35, disposal=2)
|
||||
output.seek(0)
|
||||
await evt.send(await UniMessage().image(raw=output).export())
|
||||
ac.broke_ac(CrashType.BURNT)
|
||||
await evt.send("太热啦,空调炸了!")
|
||||
return
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调降温",
|
||||
Args["temp?", Optional[Union[int, float]]] # 可选参数,降温的度数,默认为1
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||
if temp <= 0:
|
||||
return
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||
await send_ac_image(evt, ac)
|
||||
return
|
||||
ac.temperature -= temp
|
||||
if ac.temperature < 0:
|
||||
# 根据温度随机出是否冻结,0度开始,呈指数增长
|
||||
possibility = -math.e ** (ac.temperature / 50) + 1
|
||||
if random.random() < possibility:
|
||||
ac.broke_ac(CrashType.FROZEN)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"换空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
ac.change_ac()
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调炸炸排行榜",
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
number, ranking = ac.get_crashes_and_ranking()
|
||||
params = {
|
||||
"number": number,
|
||||
"ranking": ranking
|
||||
}
|
||||
image = await WebRenderer.render_file(
|
||||
file_path=ASSETS_PATH / "webpage" / "ac" / "index.html",
|
||||
target=".box",
|
||||
params=params
|
||||
)
|
||||
await evt.send(await UniMessage().image(raw=image).export())
|
||||
288
konabot/plugins/air_conditioner/ac.py
Normal file
@ -0,0 +1,288 @@
|
||||
from enum import Enum
|
||||
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
|
||||
from konabot.common.path import DATA_PATH
|
||||
import json
|
||||
|
||||
class CrashType(Enum):
|
||||
BURNT = 0
|
||||
FROZEN = 1
|
||||
|
||||
class AirConditioner:
|
||||
air_conditioners: dict[str, "AirConditioner"] = {}
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
self.id = id
|
||||
self.on = False
|
||||
self.temperature = 24 # 默认温度
|
||||
self.burnt = False
|
||||
self.frozen = False
|
||||
AirConditioner.air_conditioners[id] = self
|
||||
|
||||
def change_ac(self):
|
||||
self.burnt = False
|
||||
self.frozen = False
|
||||
self.on = False
|
||||
self.temperature = 24 # 重置为默认温度
|
||||
|
||||
def broke_ac(self, crash_type: CrashType):
|
||||
'''
|
||||
让空调坏掉,并保存数据
|
||||
|
||||
:param crash_type: CrashType 枚举,表示空调坏掉的类型
|
||||
'''
|
||||
match crash_type:
|
||||
case CrashType.BURNT:
|
||||
self.burnt = True
|
||||
case CrashType.FROZEN:
|
||||
self.frozen = True
|
||||
self.save_crash_data(crash_type)
|
||||
|
||||
def save_crash_data(self, crash_type: CrashType):
|
||||
'''
|
||||
如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
|
||||
'''
|
||||
data_file = DATA_PATH / "ac_crash_data.json"
|
||||
crash_data = {}
|
||||
if data_file.exists():
|
||||
with open(data_file, "r", encoding="utf-8") as f:
|
||||
crash_data = json.load(f)
|
||||
if self.id not in crash_data:
|
||||
crash_data[self.id] = {"burnt": 0, "frozen": 0}
|
||||
match crash_type:
|
||||
case CrashType.BURNT:
|
||||
crash_data[self.id]["burnt"] += 1
|
||||
case CrashType.FROZEN:
|
||||
crash_data[self.id]["frozen"] += 1
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(crash_data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
def get_crashes_and_ranking(self) -> tuple[int, int]:
|
||||
'''
|
||||
获取该群在全国空调损坏的数量与排行榜的位置
|
||||
'''
|
||||
data_file = DATA_PATH / "ac_crash_data.json"
|
||||
if not data_file.exists():
|
||||
return 0, 1
|
||||
with open(data_file, "r", encoding="utf-8") as f:
|
||||
crash_data = json.load(f)
|
||||
ranking_list = []
|
||||
for gid, record in crash_data.items():
|
||||
total = record.get("burnt", 0) + record.get("frozen", 0)
|
||||
ranking_list.append((gid, total))
|
||||
ranking_list.sort(key=lambda x: x[1], reverse=True)
|
||||
total_crashes = crash_data.get(self.id, {}).get("burnt", 0) + crash_data.get(self.id, {}).get("frozen", 0)
|
||||
rank = 1
|
||||
for gid, total in ranking_list:
|
||||
if gid == self.id:
|
||||
break
|
||||
rank += 1
|
||||
return total_crashes, rank
|
||||
|
||||
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)
|
||||
|
||||
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 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]
|
||||
|
||||
# 应用透视变换(保持所有通道,包括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 wiggle_transform(image, intensity=2) -> list[np.ndarray]:
|
||||
'''
|
||||
返回一组图像振动的帧组,模拟空调运作时的抖动效果
|
||||
'''
|
||||
frames = []
|
||||
height, width = image.shape[:2]
|
||||
shifts = [(-intensity, 0), (intensity, 0), (0, -intensity), (0, intensity), (0, 0)]
|
||||
for dx, dy in shifts:
|
||||
M = np.float32([[1, 0, dx], [0, 1, dy]])
|
||||
shifted = cv2.warpAffine(image, M, (width, height))
|
||||
frames.append(shifted)
|
||||
return frames
|
||||
|
||||
async def generate_ac_image(ac: AirConditioner) -> BytesIO:
|
||||
# 找到空调底图
|
||||
ac_image = cv2.imread(str(ASSETS_PATH / "img" / "ac" / "ac.png"), cv2.IMREAD_UNCHANGED)
|
||||
|
||||
if not ac.on:
|
||||
# 空调关闭状态,直接返回底图
|
||||
pil_final = Image.fromarray(ac_image)
|
||||
output = BytesIO()
|
||||
pil_final.save(output, format="GIF")
|
||||
return output
|
||||
|
||||
# 根据生成温度文本图像
|
||||
text = f"{round(ac.temperature, 1)}°C"
|
||||
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
|
||||
|
||||
# 定义3D变换的四个角点(透视效果)
|
||||
# 顺序: [左上, 右上, 右下, 左下]
|
||||
corners = np.array([
|
||||
[123, 45], # 左上
|
||||
[284, 101], # 右上
|
||||
[290, 140], # 右下
|
||||
[119, 100] # 左下
|
||||
], dtype=np.float32)
|
||||
|
||||
# 对文本图像进行3D变换(保持透明通道)
|
||||
transformed_text, transform_matrix = perspective_transform(text_image, ac_image, corners)
|
||||
|
||||
final_image_simple = blend_with_transparency(ac_image, transformed_text, (0, 0))
|
||||
|
||||
intensity = max(2, abs(int(ac.temperature) - 24) // 2)
|
||||
|
||||
frames = wiggle_transform(final_image_simple, intensity=intensity)
|
||||
pil_frames = [Image.fromarray(frame) for frame in frames]
|
||||
output = BytesIO()
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50, disposal=2)
|
||||
return output
|
||||
39
konabot/plugins/bilibili_fetch/__init__.py
Normal file
@ -0,0 +1,39 @@
|
||||
import re
|
||||
|
||||
from nonebot import on_message
|
||||
from nonebot_plugin_alconna import Reference, Reply, UniMsg
|
||||
|
||||
from nonebot.adapters import Event
|
||||
|
||||
|
||||
matcher_fix = on_message()
|
||||
|
||||
pattern = (
|
||||
r"^(?:(?:av|cv)\d+|BV[a-zA-Z0-9]{10})|"
|
||||
r"(?:b23\.tv|bili(?:22|23|33|2233)\.cn|\.bilibili\.com|QQ小程序(?:&#93;|]|\])哔哩哔哩).{0,500}"
|
||||
)
|
||||
|
||||
|
||||
@matcher_fix.handle()
|
||||
async def _(msg: UniMsg, event: Event):
|
||||
to_search = msg.exclude(Reply, Reference).dump(json=True)
|
||||
to_search2 = msg.exclude(Reply, Reference).extract_plain_text()
|
||||
if not re.search(pattern, to_search) and not re.search(pattern, to_search2):
|
||||
return
|
||||
|
||||
from nonebot_plugin_analysis_bilibili import handle_analysis
|
||||
|
||||
await handle_analysis(event)
|
||||
|
||||
# b_url: str
|
||||
# b_page: str | None
|
||||
# b_time: str | None
|
||||
#
|
||||
# from nonebot_plugin_analysis_bilibili.analysis_bilibili import extract as bilibili_extract
|
||||
#
|
||||
# b_url, b_page, b_time = bilibili_extract(to_search)
|
||||
# if b_url is None:
|
||||
# return
|
||||
#
|
||||
# await matcher_fix.send(await UniMessage().text(b_url).export())
|
||||
|
||||
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
|
||||
71
konabot/plugins/gen_qrcode/__init__.py
Normal file
@ -0,0 +1,71 @@
|
||||
import qrcode
|
||||
# from pyzbar.pyzbar import decode
|
||||
# from PIL import Image
|
||||
import requests
|
||||
from io import BytesIO
|
||||
|
||||
from nonebot_plugin_alconna import (Alconna, Args, Field, MultiVar, UniMessage,
|
||||
on_alconna)
|
||||
from nonebot_plugin_alconna.uniseg import UniMsg, At, Reply
|
||||
|
||||
from konabot.common.nb.wzq_conflict import no_wzqbot_rule
|
||||
|
||||
async def download_img(url):
|
||||
resp = requests.get(url.replace("https://multimedia.nt.qq","http://multimedia.nt.qq")) # bim获取QQ的图片时避免SSLv3报错
|
||||
img_bytes = BytesIO()
|
||||
with open(img_bytes,"wb") as f:
|
||||
f.write(resp.content)
|
||||
return img_bytes
|
||||
|
||||
def genqr(data):
|
||||
qr = qrcode.QRCode(version=1,error_correction=qrcode.constants.ERROR_CORRECT_L,box_size=8,border=4)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
return img_bytes
|
||||
|
||||
"""
|
||||
async def recqr(url):
|
||||
im_path = "assets/img/qrcode/2.jpg"
|
||||
data = await download_img(url)
|
||||
img = Image.open(im_path)
|
||||
decoded_objects = decode(img)
|
||||
data = ""
|
||||
for obj in decoded_objects:
|
||||
data += obj.data.decode('utf-8')
|
||||
return data
|
||||
"""
|
||||
|
||||
gqrc = on_alconna(Alconna(
|
||||
"genqr",
|
||||
Args["saying", MultiVar(str, '+'), Field(
|
||||
missing_tips=lambda: "请输入你要转换为二维码的文字!"
|
||||
)],
|
||||
# UniMessage[]
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"生成二维码","genqrcode"}, rule=no_wzqbot_rule)
|
||||
|
||||
@gqrc.handle()
|
||||
async def _(saying: list):
|
||||
"""
|
||||
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())
|
||||
|
||||
# print(saying)
|
||||
# 二维码识别
|
||||
if type(saying[0]) == 'image':
|
||||
data = await recqr(saying[0].data['url'])
|
||||
if data == "":
|
||||
await gqrc.send("二维码图片解析失败!")
|
||||
else:
|
||||
await gqrc.send(recqr(saying[0].data['url']))
|
||||
|
||||
# 二维码生成
|
||||
else:
|
||||
"""
|
||||
# genqr("\n".join(saying))
|
||||
await gqrc.send(await UniMessage().image(raw=genqr("\n".join(saying))).export())
|
||||
217
konabot/plugins/hanzi/__init__.py
Normal file
@ -0,0 +1,217 @@
|
||||
import random
|
||||
from typing import Optional
|
||||
import opencc
|
||||
|
||||
from nonebot import on_message
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import (
|
||||
Alconna,
|
||||
Args,
|
||||
UniMessage,
|
||||
UniMsg,
|
||||
on_alconna,
|
||||
)
|
||||
|
||||
convert_type = ["简","簡","繁","正","港","日"]
|
||||
|
||||
compiled_str = "|".join([f"{a}{mid}{b}" for mid in ["转","轉","転"] for a in convert_type for b in convert_type if a != b])
|
||||
|
||||
def hanzi_to_abbr(hanzi: str) -> str:
|
||||
mapping = {
|
||||
"简": "s",
|
||||
"簡": "s",
|
||||
"繁": "t",
|
||||
"正": "t",
|
||||
"港": "hk",
|
||||
"日": "jp",
|
||||
}
|
||||
return mapping.get(hanzi, "")
|
||||
|
||||
def check_valid_convert_type(convert_type: str) -> bool:
|
||||
avaliable_set = ["s2t","t2s","s2tw","tw2s","s2hk","hk2s","s2twp","tw2sp","t2tw","hk2t","t2hk","t2jp","jp2t","tw2t"]
|
||||
if convert_type in avaliable_set:
|
||||
return True
|
||||
return False
|
||||
|
||||
def convert(source, src_abbr, dst_abbr):
|
||||
convert_type_key = f"{src_abbr}2{dst_abbr}"
|
||||
if not check_valid_convert_type(convert_type_key):
|
||||
# 先转为繁体,再转为目标
|
||||
converter = opencc.OpenCC(f"{src_abbr}2t.json")
|
||||
source = converter.convert(source)
|
||||
src_abbr = "t"
|
||||
converter = opencc.OpenCC(f"{src_abbr}2{dst_abbr}.json")
|
||||
converted = converter.convert(source)
|
||||
return converted
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
f"re:({compiled_str})",
|
||||
Args["source?", str],
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = event.get_message().extract_plain_text()
|
||||
else:
|
||||
content = event.get_message().extract_plain_text()
|
||||
|
||||
prefix = content.split()[0]
|
||||
to_convert = ""
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(source is None):
|
||||
if event.reply:
|
||||
to_convert = event.reply.message.extract_plain_text()
|
||||
if not to_convert:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
to_convert = source
|
||||
parts = []
|
||||
if "转" in prefix:
|
||||
parts = prefix.split("转")
|
||||
elif "轉" in prefix:
|
||||
parts = prefix.split("轉")
|
||||
elif "転" in prefix:
|
||||
parts = prefix.split("転")
|
||||
if len(parts) != 2:
|
||||
notice = "转换格式错误,请使用“简转繁”、“繁转简”等格式。"
|
||||
await evt.send(await UniMessage().text(notice).export())
|
||||
return
|
||||
src, dst = parts
|
||||
src_abbr = hanzi_to_abbr(src)
|
||||
dst_abbr = hanzi_to_abbr(dst)
|
||||
if not src_abbr or not dst_abbr:
|
||||
notice = "不支持的转换类型,请使用“简”、“繁”、“正”、“港”、“日”等。"
|
||||
if src_abbr:
|
||||
notice = convert(notice, "s", src_abbr)
|
||||
await evt.send(await UniMessage().text(notice).export())
|
||||
return
|
||||
|
||||
converted = convert(to_convert, src_abbr, dst_abbr)
|
||||
|
||||
converted_prefix = convert("转换结果", "s", dst_abbr)
|
||||
|
||||
await evt.send(await UniMessage().text(f"{converted_prefix}:{converted}").export())
|
||||
|
||||
shuo = ["说","說"]
|
||||
|
||||
full_name_type = ["简体","簡體","繁體","繁体","正體","正体","港話","港话","日文"]
|
||||
|
||||
combined_list = [f"{a}{b}" for a in shuo for b in full_name_type]
|
||||
|
||||
compiled_str_2 = "|".join(combined_list)
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
f"re:({compiled_str_2})",
|
||||
Args["source?", str]
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = event.get_message().extract_plain_text()
|
||||
else:
|
||||
content = event.get_message().extract_plain_text()
|
||||
|
||||
prefix = content.split()[0]
|
||||
to_convert = ""
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(source is None):
|
||||
if event.reply:
|
||||
to_convert = event.reply.message.extract_plain_text()
|
||||
if not to_convert:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
to_convert = source
|
||||
# 获取目标转换类型
|
||||
dst = ""
|
||||
match prefix:
|
||||
case "说简体" | "說簡體" | "说簡體" | "說简体":
|
||||
dst = "简"
|
||||
case "說繁體" | "说繁体" | "說繁体" | "说繁體":
|
||||
dst = "繁"
|
||||
case "說正體" | "说正体" | "說正体" | "说正體":
|
||||
dst = "正"
|
||||
case "說港話" | "说港话" | "說港话" | "说港話":
|
||||
dst = "港"
|
||||
case "說日文" | "说日文":
|
||||
dst = "日"
|
||||
dst_abbr = hanzi_to_abbr(dst)
|
||||
if not dst_abbr:
|
||||
notice = "不支持的转换类型,请使用“简体”、“繁體”、“正體”、“港話”、“日文”等。"
|
||||
await evt.send(await UniMessage().text(notice).export())
|
||||
return
|
||||
# 循环,将源语言一次次转换为目标语言
|
||||
current_text = to_convert
|
||||
for src_abbr in ["s","hk","jp","tw","t"]:
|
||||
if src_abbr != dst_abbr:
|
||||
current_text = convert(current_text, src_abbr, dst_abbr)
|
||||
|
||||
converted_prefix = convert("转换结果", "s", dst_abbr)
|
||||
|
||||
await evt.send(await UniMessage().text(f"{converted_prefix}:{current_text}").export())
|
||||
|
||||
def random_char(char: str) -> str:
|
||||
dst_abbr = random.choice(["s","t","hk","jp","tw"])
|
||||
for src_abbr in ["s","hk","jp","tw","t"]:
|
||||
if src_abbr != dst_abbr:
|
||||
char = convert(char, src_abbr, dst_abbr)
|
||||
return char
|
||||
|
||||
def random_string(text: str) -> str:
|
||||
final_text = ""
|
||||
for char in text:
|
||||
final_text += random_char(char)
|
||||
return final_text
|
||||
|
||||
random_match = ["混乱字形","混亂字形","乱数字形","亂數字形","ランダム字形"]
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
f"re:({'|'.join(random_match)})",
|
||||
Args["source?", str]
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = event.get_message().extract_plain_text()
|
||||
else:
|
||||
content = event.get_message().extract_plain_text()
|
||||
|
||||
prefix = content.split()[0]
|
||||
to_convert = ""
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(source is None):
|
||||
if event.reply:
|
||||
to_convert = event.reply.message.extract_plain_text()
|
||||
if not to_convert:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
to_convert = source
|
||||
|
||||
final_text = ""
|
||||
final_text = random_string(to_convert)
|
||||
converted_prefix = convert(random_string("转换结果"), "s", "s")
|
||||
|
||||
await evt.send(await UniMessage().text(f"{converted_prefix}:{final_text}").export())
|
||||
684
konabot/plugins/idiomgame/__init__.py
Normal file
@ -0,0 +1,684 @@
|
||||
import asyncio as asynkio
|
||||
import datetime
|
||||
import json
|
||||
import secrets
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from nonebot import on_message
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import (
|
||||
Alconna,
|
||||
Args,
|
||||
UniMessage,
|
||||
UniMsg,
|
||||
on_alconna,
|
||||
)
|
||||
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
|
||||
from konabot.common.llm import get_llm
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
|
||||
|
||||
DATA_FILE_PATH = (
|
||||
DATA_DIR / "idiom_banned.json"
|
||||
)
|
||||
|
||||
def load_banned_ids() -> list[str]:
|
||||
if not DATA_FILE_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(DATA_FILE_PATH.read_text("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(f"在解析成语接龙封禁文件时遇到问题:{e}")
|
||||
return []
|
||||
|
||||
|
||||
def is_idiom_game_banned(group_id: str) -> bool:
|
||||
banned_ids = load_banned_ids()
|
||||
return group_id in banned_ids
|
||||
|
||||
|
||||
def add_banned_id(group_id: str):
|
||||
banned_ids = load_banned_ids()
|
||||
if group_id not in banned_ids:
|
||||
banned_ids.append(group_id)
|
||||
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4), "utf-8")
|
||||
|
||||
|
||||
def remove_banned_id(group_id: str):
|
||||
banned_ids = load_banned_ids()
|
||||
if group_id in banned_ids:
|
||||
banned_ids.remove(group_id)
|
||||
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4), "utf-8")
|
||||
|
||||
|
||||
class TryStartState(Enum):
|
||||
STARTED = 0
|
||||
ALREADY_PLAYING = 1
|
||||
NO_REMAINING_TIMES = 2
|
||||
|
||||
|
||||
class TryStopState(Enum):
|
||||
STOPPED = 0
|
||||
NOT_PLAYING = 1
|
||||
|
||||
|
||||
class TryVerifyState(Enum):
|
||||
VERIFIED = 0
|
||||
VERIFIED_AND_REAL = 1
|
||||
ALREADY_USED = 2
|
||||
NOT_IDIOM = 3
|
||||
WRONG_FIRST_CHAR = 4
|
||||
BUT_NO_NEXT = 5
|
||||
GAME_END = 6
|
||||
|
||||
class IdiomGameLLM:
|
||||
@classmethod
|
||||
async def verify_idiom_with_llm(cls, idiom: str) -> bool:
|
||||
if len(idiom) != 4:
|
||||
return False
|
||||
llm = get_llm()
|
||||
system_prompt = "请判断用户的输入是否为一个合理的成语,或者这四个字在中文环境下是否说得通。如果是请回答「T」,否则回答「F」。请注意,即使这个词不是成语,如果说得通(也就是能念起来很通顺),你也该输出「T」。请不要包含任何解释,也不要包含任何标点符号。"
|
||||
message = await llm.chat([{"role": "system", "content": system_prompt}, {"role": "user", "content": idiom}])
|
||||
answer = message.content
|
||||
logger.info(f"LLM 对成语 {idiom} 的判断结果是 {answer}")
|
||||
if answer == "T":
|
||||
await cls.storage_idiom(idiom)
|
||||
return answer == "T"
|
||||
|
||||
@classmethod
|
||||
async def storage_idiom(cls, idiom: str):
|
||||
# 将 idiom 存入本地文件以备后续分析
|
||||
with open(DATA_DIR / "idiom_llm_storage.txt", "a", encoding="utf-8") as f:
|
||||
f.write(idiom + "\n")
|
||||
IdiomGame.append_into_word_list(idiom)
|
||||
|
||||
|
||||
class IdiomGame:
|
||||
ALL_WORDS = [] # 所有四字词语
|
||||
ALL_IDIOMS = [] # 所有成语
|
||||
INSTANCE_LIST: dict[str, "IdiomGame"] = {} # 群号对应的游戏实例
|
||||
IDIOM_FIRST_CHAR = {} # 所有成语包括词语的首字字典
|
||||
AVALIABLE_IDIOM_FIRST_CHAR = {} # 真正有效的成语首字字典
|
||||
|
||||
__inited = False
|
||||
|
||||
def __init__(self, group_id: str):
|
||||
# 初始化一局游戏
|
||||
self.group_id = ""
|
||||
self.now_playing = False
|
||||
self.score_board = {}
|
||||
self.last_idiom = ""
|
||||
self.last_char = ""
|
||||
self.remain_playing_times = 3
|
||||
self.last_play_date = ""
|
||||
self.all_buff_score = 0
|
||||
self.lock = asynkio.Lock()
|
||||
self.remain_rounds = 0 # 剩余回合数
|
||||
self.already_idioms: dict[str, int] = {} # 已经使用过的成语和使用过的次数
|
||||
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
|
||||
IdiomGame.INSTANCE_LIST[group_id] = self
|
||||
|
||||
@classmethod
|
||||
def append_into_word_list(cls, word: str):
|
||||
'''
|
||||
将一个新词加入到词语列表中
|
||||
'''
|
||||
if word not in cls.ALL_WORDS:
|
||||
cls.ALL_WORDS.append(word)
|
||||
if word[0] not in cls.IDIOM_FIRST_CHAR:
|
||||
cls.IDIOM_FIRST_CHAR[word[0]] = []
|
||||
cls.IDIOM_FIRST_CHAR[word[0]].append(word)
|
||||
|
||||
def be_able_to_play(self) -> bool:
|
||||
if self.last_play_date != datetime.date.today():
|
||||
self.last_play_date = datetime.date.today()
|
||||
self.remain_playing_times = 3
|
||||
if self.remain_playing_times > 0:
|
||||
self.remain_playing_times -= 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def choose_start_idiom(self) -> str:
|
||||
"""
|
||||
随机选择一个成语作为起始成语
|
||||
"""
|
||||
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
|
||||
self.last_char = self.last_idiom[-1]
|
||||
if not self.is_nextable(self.last_char):
|
||||
self.choose_start_idiom()
|
||||
else:
|
||||
self.add_history_idiom(self.last_idiom, new_chain=True)
|
||||
return self.last_idiom
|
||||
|
||||
@classmethod
|
||||
def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
|
||||
cls.init_lexicon()
|
||||
if not cls.INSTANCE_LIST.get(group_id):
|
||||
cls(group_id)
|
||||
instance = cls.INSTANCE_LIST[group_id]
|
||||
if instance.now_playing:
|
||||
return TryStartState.ALREADY_PLAYING
|
||||
if not instance.be_able_to_play() and not force:
|
||||
return TryStartState.NO_REMAINING_TIMES
|
||||
instance.now_playing = True
|
||||
return TryStartState.STARTED
|
||||
|
||||
def start_game(self, rounds: int = 100):
|
||||
self.now_playing = True
|
||||
self.remain_rounds = rounds
|
||||
self.choose_start_idiom()
|
||||
|
||||
@classmethod
|
||||
def try_stop_game(cls, group_id: str) -> TryStopState:
|
||||
if not cls.INSTANCE_LIST.get(group_id):
|
||||
return TryStopState.NOT_PLAYING
|
||||
instance = cls.INSTANCE_LIST[group_id]
|
||||
if not instance.now_playing:
|
||||
return TryStopState.NOT_PLAYING
|
||||
instance.now_playing = False
|
||||
return TryStopState.STOPPED
|
||||
|
||||
def clear_score_board(self):
|
||||
self.score_board = {}
|
||||
self.last_char = ""
|
||||
self.all_buff_score = 0
|
||||
self.already_idioms = {}
|
||||
self.idiom_history = []
|
||||
|
||||
def get_score_board(self) -> dict:
|
||||
return self.score_board
|
||||
|
||||
def get_all_buff_score(self) -> int:
|
||||
return self.all_buff_score
|
||||
|
||||
async def skip_idiom(self, buff_score: int = -100) -> str:
|
||||
"""
|
||||
跳过当前成语,选择下一个成语
|
||||
"""
|
||||
async with self.lock:
|
||||
self._skip_idiom_async()
|
||||
self.add_buff_score(buff_score)
|
||||
return self.last_idiom
|
||||
|
||||
def _skip_idiom_async(self) -> str:
|
||||
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
|
||||
self.last_char = self.last_idiom[-1]
|
||||
if not self.is_nextable(self.last_char):
|
||||
self._skip_idiom_async()
|
||||
else:
|
||||
self.add_history_idiom(self.last_idiom, new_chain=True)
|
||||
return self.last_idiom
|
||||
|
||||
async def try_verify_idiom(self, idiom: str, user_id: str) -> TryVerifyState:
|
||||
"""
|
||||
用户发送成语
|
||||
"""
|
||||
async with self.lock:
|
||||
state = await self._verify_idiom(idiom, user_id)
|
||||
return state
|
||||
|
||||
def is_nextable(self, last_char: str) -> bool:
|
||||
"""
|
||||
判断是否有成语可以接
|
||||
"""
|
||||
return last_char in IdiomGame.AVALIABLE_IDIOM_FIRST_CHAR
|
||||
|
||||
def add_already_idiom(self, idiom: str):
|
||||
if idiom in self.already_idioms:
|
||||
self.already_idioms[idiom] += 1
|
||||
else:
|
||||
self.already_idioms[idiom] = 1
|
||||
|
||||
def get_already_used_num(self, idiom: str) -> int:
|
||||
if idiom in self.already_idioms:
|
||||
return self.already_idioms[idiom]
|
||||
return 0
|
||||
|
||||
def add_history_idiom(self, idiom: str, new_chain: bool = False):
|
||||
if new_chain or len(self.idiom_history) == 0:
|
||||
self.idiom_history.append([idiom])
|
||||
else:
|
||||
self.idiom_history[-1].append(idiom)
|
||||
|
||||
def display_history(self) -> list[str]:
|
||||
result = []
|
||||
for chain in self.idiom_history:
|
||||
result.append(" -> ".join(chain))
|
||||
return result
|
||||
|
||||
async def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
||||
state = []
|
||||
# 新成语的首字应与上一条成语的尾字相同
|
||||
if idiom[0] != self.last_char:
|
||||
state.append(TryVerifyState.WRONG_FIRST_CHAR)
|
||||
return state
|
||||
if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
|
||||
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
|
||||
try:
|
||||
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
|
||||
self.add_score(user_id, -0.1)
|
||||
state.append(TryVerifyState.NOT_IDIOM)
|
||||
return state
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 验证成语 {idiom} 时出现错误:{e}")
|
||||
self.add_score(user_id, -0.1)
|
||||
state.append(TryVerifyState.NOT_IDIOM)
|
||||
return state
|
||||
# 成语合法,更新状态
|
||||
self.add_history_idiom(idiom)
|
||||
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
|
||||
if(score_k != 1):
|
||||
state.append(TryVerifyState.ALREADY_USED)
|
||||
self.add_already_idiom(idiom)
|
||||
state.append(TryVerifyState.VERIFIED)
|
||||
self.last_idiom = idiom
|
||||
self.last_char = idiom[-1]
|
||||
self.add_score(user_id, 1 * score_k) # 先加 1 分
|
||||
if idiom in IdiomGame.ALL_IDIOMS:
|
||||
state.append(TryVerifyState.VERIFIED_AND_REAL)
|
||||
self.add_score(user_id, 4 * score_k) # 再加 4 分
|
||||
self.remain_rounds -= 1
|
||||
if self.remain_rounds <= 0:
|
||||
self.now_playing = False
|
||||
state.append(TryVerifyState.GAME_END)
|
||||
if not self.is_nextable(self.last_char):
|
||||
# 没有成语可以接了,自动跳过
|
||||
self._skip_idiom_async()
|
||||
self.add_buff_score(-100)
|
||||
state.append(TryVerifyState.BUT_NO_NEXT)
|
||||
return state
|
||||
|
||||
def get_user_score(self, user_id: str) -> float:
|
||||
if user_id not in self.score_board:
|
||||
return 0
|
||||
# 避免浮点数精度问题导致过长
|
||||
handled_score = round(self.score_board[user_id]["score"] + self.all_buff_score, 1)
|
||||
return handled_score
|
||||
|
||||
def add_score(self, user_id: str, score: int):
|
||||
if user_id not in self.score_board:
|
||||
self.score_board[user_id] = {"name": user_id, "score": 0}
|
||||
self.score_board[user_id]["score"] += score
|
||||
|
||||
def add_buff_score(self, score: int):
|
||||
self.all_buff_score += score
|
||||
|
||||
def get_playing_state(self) -> bool:
|
||||
return self.now_playing
|
||||
|
||||
def get_last_char(self) -> str:
|
||||
return self.last_char
|
||||
|
||||
@classmethod
|
||||
def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
|
||||
cls.init_lexicon()
|
||||
if first_char not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
|
||||
return None
|
||||
return secrets.choice(cls.AVALIABLE_IDIOM_FIRST_CHAR[first_char])
|
||||
|
||||
@classmethod
|
||||
def init_lexicon(cls):
|
||||
if cls.__inited:
|
||||
return
|
||||
cls.__inited = True
|
||||
|
||||
# 成语大表
|
||||
with open(ASSETS_PATH / "lexicon" / "idiom.json", "r", encoding="utf-8") as f:
|
||||
ALL_IDIOMS_INFOS = json.load(f)
|
||||
|
||||
# 词语大表
|
||||
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
|
||||
jsonData = json.load(f)
|
||||
cls.ALL_WORDS = [item["ci"] for item in jsonData]
|
||||
logger.debug(f"Loaded {len(cls.ALL_WORDS)} words from ci.json")
|
||||
logger.debug(f"Sample words: {cls.ALL_WORDS[:5]}")
|
||||
|
||||
COMMON_WORDS = []
|
||||
# 读取 COMMON 词语大表
|
||||
with open(ASSETS_PATH / "lexicon" / "common.txt", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
word = line.strip()
|
||||
if len(word) == 4:
|
||||
COMMON_WORDS.append(word)
|
||||
logger.debug(f"Loaded {len(COMMON_WORDS)} common words from common.txt")
|
||||
logger.debug(f"Sample common words: {COMMON_WORDS[:5]}")
|
||||
|
||||
# 读取 THUOCL 成语库
|
||||
with open(
|
||||
ASSETS_PATH / "lexicon" / "THUOCL" / "data" / "THUOCL_chengyu.txt",
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
THUOCL_IDIOMS = [line.split(" ")[0].split("\t")[0].strip() for line in f]
|
||||
logger.debug(f"Loaded {len(THUOCL_IDIOMS)} idioms from THUOCL_chengyu.txt")
|
||||
logger.debug(f"Sample idioms: {THUOCL_IDIOMS[:5]}")
|
||||
|
||||
# 读取 THUOCL 剩下的所有 txt 文件,只保留四字词
|
||||
THUOCL_WORDS = []
|
||||
import os
|
||||
|
||||
for filename in os.listdir(ASSETS_PATH / "lexicon" / "THUOCL" / "data"):
|
||||
if filename.endswith(".txt") and filename != "THUOCL_chengyu.txt":
|
||||
with open(
|
||||
ASSETS_PATH / "lexicon" / "THUOCL" / "data" / filename,
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
for line in f:
|
||||
word = line.lstrip().split(" ")[0].split("\t")[0].strip()
|
||||
if len(word) == 4:
|
||||
THUOCL_WORDS.append(word)
|
||||
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
|
||||
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
|
||||
|
||||
# 读取本地的 idiom_llm_storage.txt 文件,补充词语表
|
||||
LOCAL_LLM_WORDS = []
|
||||
if (DATA_DIR / "idiom_llm_storage.txt").exists():
|
||||
with open(DATA_DIR / "idiom_llm_storage.txt", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
word = line.strip()
|
||||
if len(word) == 4:
|
||||
LOCAL_LLM_WORDS.append(word)
|
||||
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
|
||||
|
||||
# 只有成语的大表
|
||||
cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
|
||||
cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
|
||||
|
||||
# 其他四字词语表,仅表示可以有这个词
|
||||
cls.ALL_WORDS = (
|
||||
[word for word in cls.ALL_WORDS if len(word) == 4]
|
||||
+ THUOCL_WORDS
|
||||
+ COMMON_WORDS
|
||||
+ LOCAL_LLM_WORDS
|
||||
)
|
||||
cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
|
||||
|
||||
# 根据成语大表,划分出成语首字字典
|
||||
for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
|
||||
if idiom[0] not in cls.IDIOM_FIRST_CHAR:
|
||||
cls.IDIOM_FIRST_CHAR[idiom[0]] = []
|
||||
cls.IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
|
||||
|
||||
# 根据真正的成语大表,划分出有效成语首字字典
|
||||
for idiom in cls.ALL_IDIOMS:
|
||||
if idiom[0] not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
|
||||
cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]] = []
|
||||
cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
|
||||
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
"我要玩成语接龙",
|
||||
Args["rounds?", int],
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def play_game(
|
||||
event: BaseEvent,
|
||||
target: DepLongTaskTarget,
|
||||
force=False,
|
||||
rounds: Optional[int] = 100,
|
||||
):
|
||||
# group_id = str(event.get_session_id())
|
||||
group_id = target.channel_id
|
||||
if is_idiom_game_banned(group_id):
|
||||
await evt.send(
|
||||
await UniMessage().text("本群已被禁止使用成语接龙功能!").export()
|
||||
)
|
||||
return
|
||||
rounds = rounds or 0
|
||||
if rounds <= 0:
|
||||
await evt.send(await UniMessage().text("干什么!你想玩负数局吗?").export())
|
||||
return
|
||||
state = IdiomGame.try_start_game(group_id, force)
|
||||
if state == TryStartState.ALREADY_PLAYING:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.text("当前已有成语接龙游戏在进行中,请稍后再试!")
|
||||
.export()
|
||||
)
|
||||
return
|
||||
if state == TryStartState.NO_REMAINING_TIMES:
|
||||
await evt.send(await UniMessage().text("玩玩玩,就知道玩,快去睡觉!").export())
|
||||
return
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.text(
|
||||
"你小子,还真有意思!\n好,成语接龙游戏开始!我说一个成语,请大家接下去!"
|
||||
)
|
||||
.export()
|
||||
)
|
||||
instance = IdiomGame.INSTANCE_LIST[group_id]
|
||||
instance.start_game(rounds)
|
||||
# 发布成语
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.text(f"第一个成语:「{instance.last_idiom}」,请接!")
|
||||
.export()
|
||||
)
|
||||
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
"老子就是要玩成语接龙!!!",
|
||||
Args["rounds?", int],
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def force_play_game(
|
||||
event: BaseEvent, target: DepLongTaskTarget, rounds: Optional[int] = 100
|
||||
):
|
||||
await play_game(event, target, force=True, rounds=rounds)
|
||||
|
||||
|
||||
async def end_game(event: BaseEvent, group_id: str):
|
||||
instance = IdiomGame.INSTANCE_LIST[group_id]
|
||||
result_text = UniMessage().text("游戏结束!\n最终得分榜:\n")
|
||||
score_board = instance.get_score_board()
|
||||
if len(score_board) == 0:
|
||||
result_text += "无人得分!\n"
|
||||
else:
|
||||
# 按分数排序,名字用 at 的方式
|
||||
sorted_score = sorted(
|
||||
score_board.items(), key=lambda x: x[1]["score"], reverse=True
|
||||
)
|
||||
for i, (user_id, info) in enumerate(sorted_score):
|
||||
result_text += (
|
||||
f"{i + 1}. "
|
||||
+ UniMessage().at(user_id)
|
||||
+ f": {round(info['score'] + instance.get_all_buff_score(), 1)} 分\n"
|
||||
)
|
||||
if len(instance.idiom_history) == 0:
|
||||
result_text += "\n本局没有任何接龙记录。"
|
||||
else:
|
||||
result_text += "\n你们的接龙记录是:\n"
|
||||
history_lines = instance.display_history()
|
||||
for line in history_lines:
|
||||
result_text += line + "\n"
|
||||
await evt.send(await result_text.export())
|
||||
instance.clear_score_board()
|
||||
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna("不玩了"), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
|
||||
)
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
# group_id = str(event.get_session_id())
|
||||
group_id = target.channel_id
|
||||
state = IdiomGame.try_stop_game(group_id)
|
||||
if state == TryStopState.STOPPED:
|
||||
# 发送好吧狗图片
|
||||
# 打开好吧狗本地文件
|
||||
with open(ASSETS_PATH / "img" / "dog" / "haoba_dog.jpg", "rb") as f:
|
||||
img_data = f.read()
|
||||
await evt.send(await UniMessage().image(raw=img_data).export())
|
||||
await end_game(event, group_id)
|
||||
else:
|
||||
await evt.send(
|
||||
await UniMessage().text("当前没有成语接龙游戏在进行中!").export()
|
||||
)
|
||||
|
||||
|
||||
# 跳过
|
||||
evt = on_alconna(
|
||||
Alconna("跳过成语"), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
|
||||
)
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
# group_id = str(event.get_session_id())
|
||||
group_id = target.channel_id
|
||||
instance = IdiomGame.INSTANCE_LIST.get(group_id)
|
||||
if not instance or not instance.get_playing_state():
|
||||
return
|
||||
avaliable_idiom = IdiomGame.random_idiom_starting_with(instance.get_last_char())
|
||||
# 发送哈哈狗图片
|
||||
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
|
||||
img_data = f.read()
|
||||
await evt.send(await UniMessage().image(raw=img_data).export())
|
||||
await evt.send(await UniMessage().text(f"你们太菜了,全部扣100分!明明还可以接「{avaliable_idiom}」的!").export())
|
||||
idiom = await instance.skip_idiom(-100)
|
||||
await evt.send(
|
||||
await UniMessage().text(f"重新开始,下一个成语是「{idiom}」").export()
|
||||
)
|
||||
|
||||
|
||||
def get_user_info(event: BaseEvent):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
user_id = str(event.author.id)
|
||||
user_name = str(event.author.name)
|
||||
else:
|
||||
user_id = str(event.get_user_id())
|
||||
user_name = str(event.get_user_id())
|
||||
return user_id, user_name
|
||||
|
||||
|
||||
# 直接读取消息
|
||||
evt = on_message()
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, msg: UniMsg, target: DepLongTaskTarget):
|
||||
# group_id = str(event.get_session_id())
|
||||
group_id = target.channel_id
|
||||
instance = IdiomGame.INSTANCE_LIST.get(group_id)
|
||||
if not instance or not instance.get_playing_state():
|
||||
return
|
||||
user_idiom = msg.extract_plain_text().strip()
|
||||
user_id, user_name = get_user_info(event)
|
||||
state = await instance.try_verify_idiom(user_idiom, user_id)
|
||||
if TryVerifyState.WRONG_FIRST_CHAR in state:
|
||||
return
|
||||
if TryVerifyState.NOT_IDIOM in state:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(" 接不上!这个不一样!你被扣了 0.1 分!")
|
||||
.export()
|
||||
)
|
||||
return
|
||||
already_used_num = instance.get_already_used_num(user_idiom)
|
||||
if TryVerifyState.VERIFIED_AND_REAL in state:
|
||||
score = 5 * (0.5 ** (already_used_num - 1))
|
||||
if already_used_num > 1:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,这是个被重复用过的成语,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
else:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,这是个真实成语,喜提 5 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
elif TryVerifyState.VERIFIED in state:
|
||||
score = 1 * (0.5 ** (already_used_num - 1))
|
||||
if already_used_num > 1:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,但重复了,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
else:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,喜提 1 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
if TryVerifyState.GAME_END in state:
|
||||
await evt.send(await UniMessage().text("全部回合结束!").export())
|
||||
await end_game(event, group_id)
|
||||
return
|
||||
if TryVerifyState.BUT_NO_NEXT in state:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.text("但是,这是条死路!你们全部都要扣 100 分!")
|
||||
.export()
|
||||
)
|
||||
await evt.send(
|
||||
await UniMessage().text(f"重新抽取成语「{instance.last_idiom}」").export()
|
||||
)
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.text(f"下一个成语请以「{instance.get_last_char()}」开头!")
|
||||
.export()
|
||||
)
|
||||
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna("禁止成语接龙"),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
# group_id = str(event.get_session_id())
|
||||
group_id = target.channel_id
|
||||
add_banned_id(group_id)
|
||||
await evt.send(await UniMessage().text("本群已被禁止使用成语接龙功能!").export())
|
||||
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna("开启成语接龙"),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
# group_id = str(event.get_session_id())
|
||||
group_id = target.channel_id
|
||||
remove_banned_id(group_id)
|
||||
await evt.send(await UniMessage().text("本群已开启成语接龙功能!").export())
|
||||
3
konabot/plugins/idiomgame/base/path.py
Normal file
@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS = Path(__file__).parent.parent / "assets"
|
||||
201
konabot/plugins/image_process/__init__.py
Normal file
@ -0,0 +1,201 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
import PIL.Image
|
||||
from nonebot import on_message
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna
|
||||
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
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"))
|
||||
|
||||
|
||||
def parse_timestamp(tx: str) -> float | None:
|
||||
res = 0.0
|
||||
for component in tx.split(":"):
|
||||
res *= 60
|
||||
if not re.match(r"^\d+(\.\d+)?$", component):
|
||||
return
|
||||
res += float(component)
|
||||
return res
|
||||
|
||||
|
||||
cmd_giftool = on_alconna(
|
||||
Alconna(
|
||||
"giftool",
|
||||
Args["img", Image | None],
|
||||
Option("--ss", Args["start_point", str]),
|
||||
Option("--frames:v", Args["frame_count", int]),
|
||||
Option("-t", Args["length", str]),
|
||||
Option("-to", Args["end_point", str]),
|
||||
Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@cmd_giftool.handle()
|
||||
async def _(
|
||||
image: PIL_Image,
|
||||
start_point: str | None = None,
|
||||
frame_count: int | None = None,
|
||||
length: str | None = None,
|
||||
speed_factor: float = 1.0,
|
||||
end_point: str | None = None,
|
||||
):
|
||||
ss: None | float = None
|
||||
if start_point:
|
||||
ss = parse_timestamp(start_point)
|
||||
if ss is None:
|
||||
raise BotExceptionMessage("--ss 的格式不满足条件")
|
||||
|
||||
t: None | float = None
|
||||
if length:
|
||||
t = parse_timestamp(length)
|
||||
if t is None:
|
||||
raise BotExceptionMessage("-t 的格式不满足条件")
|
||||
|
||||
to: None | float = None
|
||||
if end_point:
|
||||
to = parse_timestamp(end_point)
|
||||
if to is None:
|
||||
raise BotExceptionMessage("-to 的格式不满足条件")
|
||||
|
||||
if to is not None and ss is not None and to <= ss:
|
||||
raise BotExceptionMessage("错误:出点时间小于入点")
|
||||
if frame_count is not None and frame_count <= 0:
|
||||
raise BotExceptionMessage("错误:帧数量应该大于 0")
|
||||
if speed_factor == 0:
|
||||
raise BotExceptionMessage("错误:速度不能为 0")
|
||||
|
||||
is_rev = speed_factor < 0
|
||||
speed_factor = abs(speed_factor)
|
||||
|
||||
if not getattr(image, "is_animated", False):
|
||||
raise BotExceptionMessage("错误:输入的不是动图(GIF)")
|
||||
|
||||
##
|
||||
# 从这里开始,采样整个 GIF 图
|
||||
frames: list[PIL.Image.Image] = []
|
||||
durations: list[float] = []
|
||||
try:
|
||||
for i in range(getattr(image, "n_frames")):
|
||||
image.seek(i)
|
||||
frames.append(image.copy())
|
||||
duration = image.info.get("duration", 100) / 1000
|
||||
durations.append(duration)
|
||||
except EOFError:
|
||||
pass
|
||||
if not frames:
|
||||
raise BotExceptionMessage("错误:读取 GIF 帧失败")
|
||||
# 采样结束
|
||||
|
||||
##
|
||||
# 根据开始、结束时间或者帧数量来裁取 GIF 图
|
||||
|
||||
begin_time = ss or 0
|
||||
end_time = sum(durations)
|
||||
end_time = min(begin_time + (t or end_time), to or end_time, end_time)
|
||||
|
||||
accumulated = 0.0
|
||||
status = 0
|
||||
|
||||
sel_frames: list[PIL.Image.Image] = []
|
||||
sel_durations: list[float] = []
|
||||
|
||||
for i in range(len(frames)):
|
||||
frame = frames[i]
|
||||
duration = durations[i]
|
||||
|
||||
if status == 0:
|
||||
if accumulated + duration > begin_time:
|
||||
status = 1
|
||||
sel_frames.append(frame)
|
||||
sel_durations.append(accumulated + duration - begin_time)
|
||||
elif status == 1:
|
||||
if accumulated + duration > end_time:
|
||||
sel_frames.append(frame)
|
||||
sel_durations.append(end_time - accumulated)
|
||||
break
|
||||
sel_frames.append(frame)
|
||||
sel_durations.append(duration)
|
||||
|
||||
accumulated += duration
|
||||
|
||||
##
|
||||
# 加速!
|
||||
sel_durations = [dur / speed_factor * 1000 for dur in durations]
|
||||
|
||||
rframes = []
|
||||
rdur = []
|
||||
|
||||
acc_mod_20 = 0
|
||||
|
||||
for i in range(len(sel_frames)):
|
||||
fr = sel_frames[i]
|
||||
du = round(sel_durations[i])
|
||||
|
||||
if du >= 20:
|
||||
rframes.append(fr)
|
||||
rdur.append(int(du))
|
||||
acc_mod_20 = 0
|
||||
else:
|
||||
if acc_mod_20 == 0:
|
||||
rframes.append(fr)
|
||||
rdur.append(20)
|
||||
acc_mod_20 += du
|
||||
else:
|
||||
acc_mod_20 += du
|
||||
if acc_mod_20 >= 20:
|
||||
acc_mod_20 = 0
|
||||
|
||||
if len(rframes) == 1 and len(sel_frames) > 1:
|
||||
rframes.append(sel_frames[max(2, len(sel_frames) // 2)])
|
||||
rdur.append(20)
|
||||
|
||||
##
|
||||
# 收尾:看看透明度这块
|
||||
transparency_flag = False
|
||||
for f in rframes:
|
||||
if f.mode == "RGBA":
|
||||
if any(pix < 255 for pix in f.getchannel("A").getdata()):
|
||||
transparency_flag = True
|
||||
break
|
||||
elif f.mode == "P" and "transparency" in f.info:
|
||||
transparency_flag = True
|
||||
break
|
||||
|
||||
tf = {}
|
||||
if transparency_flag:
|
||||
tf["transparency"] = 0
|
||||
|
||||
if is_rev:
|
||||
rframes = rframes[::-1]
|
||||
rdur = rdur[::-1]
|
||||
|
||||
output_img = BytesIO()
|
||||
if rframes:
|
||||
rframes[0].save(
|
||||
output_img,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=rframes[1:],
|
||||
duration=rdur,
|
||||
loop=0,
|
||||
optimize=False,
|
||||
disposal=2,
|
||||
**tf,
|
||||
)
|
||||
else:
|
||||
raise BotExceptionMessage("错误:没有可输出的帧")
|
||||
output_img.seek(0)
|
||||
|
||||
await cmd_giftool.send(await UniMessage().image(raw=output_img).export())
|
||||
138
konabot/plugins/kona_ph/__init__.py
Normal file
@ -0,0 +1,138 @@
|
||||
import datetime
|
||||
import re
|
||||
from math import ceil
|
||||
|
||||
from loguru import logger
|
||||
from nonebot import on_message
|
||||
import nonebot
|
||||
from nonebot.rule import to_me
|
||||
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
|
||||
on_alconna)
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.plugins.kona_ph.core.message import (get_daily_report,
|
||||
get_daily_report_v2,
|
||||
get_puzzle_description,
|
||||
get_submission_message)
|
||||
from konabot.plugins.kona_ph.core.storage import get_today_date
|
||||
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE,
|
||||
create_admin_commands,
|
||||
puzzle_manager)
|
||||
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
|
||||
from konabot.plugins.poster.service import broadcast
|
||||
|
||||
create_admin_commands()
|
||||
register_poster_info("每日谜题", info=PosterInfo(
|
||||
aliases={"konaph", "kona_ph", "KonaPH", "此方谜题", "KONAPH"},
|
||||
description="此方 BOT 每日谜题推送",
|
||||
))
|
||||
|
||||
|
||||
cmd_submit = on_message(rule=to_me())
|
||||
|
||||
|
||||
@cmd_submit.handle()
|
||||
async def _(msg: UniMsg, target: DepLongTaskTarget):
|
||||
txt = msg.extract_plain_text().strip()
|
||||
if match := re.match(r"^提交(?:答案|题解|[fF]lag)\s*(?P<submission>.+?)\s*$", txt):
|
||||
submission: str = match.group("submission")
|
||||
async with puzzle_manager() as manager:
|
||||
result = manager.submit(target.target_id, submission)
|
||||
if isinstance(result, str):
|
||||
await target.send_message(result)
|
||||
else:
|
||||
await target.send_message(get_submission_message(
|
||||
daily_puzzle_info=result.info,
|
||||
submission=result.submission,
|
||||
puzzle=result.puzzle,
|
||||
))
|
||||
|
||||
|
||||
cmd_query = on_alconna(Alconna(
|
||||
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
||||
), rule=to_me())
|
||||
|
||||
@cmd_query.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
async with puzzle_manager() as manager:
|
||||
p = manager.get_today_puzzle()
|
||||
if p is None:
|
||||
return await target.send_message("今天无题,改日再来吧!")
|
||||
await target.send_message(get_puzzle_description(p))
|
||||
|
||||
|
||||
cmd_query_submission = on_alconna(Alconna(
|
||||
"今日答题情况"
|
||||
), rule=to_me())
|
||||
|
||||
@cmd_query_submission.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
gid = None
|
||||
if re.match(r"^\d+$", target.channel_id):
|
||||
gid = int(target.channel_id)
|
||||
async with puzzle_manager() as manager:
|
||||
await target.send_message(get_daily_report_v2(manager, gid))
|
||||
|
||||
|
||||
cmd_history = on_alconna(Alconna(
|
||||
"历史题目",
|
||||
Args["page?", int],
|
||||
Args["index_id?", str],
|
||||
), rule=to_me())
|
||||
|
||||
@cmd_history.handle()
|
||||
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
||||
async with puzzle_manager() as manager:
|
||||
today = get_today_date()
|
||||
if index_id:
|
||||
index_id = index_id.removeprefix("#")
|
||||
if index_id not in manager.daily_puzzle:
|
||||
return await target.send_message("没有这道题哦")
|
||||
puzzle = manager.puzzle_data[manager.daily_puzzle[index_id].raw_id]
|
||||
msg = get_puzzle_description(
|
||||
puzzle,
|
||||
with_answer=(index_id != manager.daily_puzzle_of_date.get(today, "")),
|
||||
)
|
||||
return await target.send_message(msg)
|
||||
msg = UniMessage.text("====== 历史题目清单 ======\n\n")
|
||||
puzzles = [
|
||||
(manager.puzzle_data[manager.daily_puzzle[i].raw_id], d)
|
||||
for d, i in manager.daily_puzzle_of_date.items()
|
||||
]
|
||||
puzzles = sorted(puzzles, key=lambda u: u[1], reverse=True)
|
||||
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
||||
if page <= 0 or page > count_pages:
|
||||
return await target.send_message(UniMessage.text(
|
||||
f"页数只有 1 ~ {count_pages} 啦!"
|
||||
))
|
||||
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
|
||||
for p, d in puzzles:
|
||||
info = manager.daily_puzzle[manager.daily_puzzle_of_date[d]]
|
||||
msg = msg.text(
|
||||
f"- [#{p.index_id}: {len(info.success_users)}/{len(info.tried_users)}]"
|
||||
f" {p.title} ({d})"
|
||||
)
|
||||
msg = msg.text("\n")
|
||||
msg = msg.text(f"\n==== 第 {page} 页,共 {count_pages} 页 ====")
|
||||
await target.send_message(msg)
|
||||
|
||||
|
||||
@scheduler.scheduled_job("cron", hour="8")
|
||||
async def _():
|
||||
async with puzzle_manager() as manager:
|
||||
yesterday = get_today_date() - datetime.timedelta(days=1)
|
||||
msg2 = get_daily_report(manager, yesterday)
|
||||
if msg2 is not None:
|
||||
await broadcast("每日谜题", msg2)
|
||||
|
||||
puzzle = manager.get_today_puzzle()
|
||||
if puzzle is not None:
|
||||
logger.info(f"找到了题目 {puzzle.raw_id},发送")
|
||||
await broadcast("每日谜题", get_puzzle_description(puzzle))
|
||||
else:
|
||||
logger.info("自动任务:没有找到题目,跳过")
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
0
konabot/plugins/kona_ph/core/__init__.py
Normal file
29
konabot/plugins/kona_ph/core/image.py
Normal file
@ -0,0 +1,29 @@
|
||||
import nanoid
|
||||
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
from konabot.plugins.kona_ph.core.path import KONAPH_IMAGE_BASE
|
||||
|
||||
|
||||
class PuzzleImageManager:
|
||||
def read_puzzle_image(self, img_name: str) -> bytes:
|
||||
fp = KONAPH_IMAGE_BASE / img_name
|
||||
if fp.exists():
|
||||
return fp.read_bytes()
|
||||
return (ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes()
|
||||
|
||||
def upload_puzzle_image(self, data: bytes, suffix: str = ".png") -> str:
|
||||
id = nanoid.generate(
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz",
|
||||
21,
|
||||
)
|
||||
img_name = f"{id}{suffix}"
|
||||
(KONAPH_IMAGE_BASE / img_name).write_bytes(data)
|
||||
return img_name
|
||||
|
||||
def remove_puzzle_image(self, img_name: str):
|
||||
if img_name:
|
||||
(KONAPH_IMAGE_BASE / img_name).unlink(True)
|
||||
|
||||
|
||||
def get_image_manager() -> PuzzleImageManager:
|
||||
return PuzzleImageManager()
|
||||
187
konabot/plugins/kona_ph/core/message.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""
|
||||
生成各种各样的 Message 的函数集合
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
from konabot.common.username import get_username
|
||||
from konabot.plugins.kona_ph.core.image import get_image_manager
|
||||
from konabot.plugins.kona_ph.core.storage import (DailyPuzzleInfo, Puzzle,
|
||||
PuzzleManager,
|
||||
PuzzleSubmission)
|
||||
|
||||
|
||||
def get_puzzle_description(puzzle: Puzzle, with_answer: bool = False) -> UniMessage[Any]:
|
||||
"""
|
||||
获取一个谜题的描述
|
||||
"""
|
||||
|
||||
img_manager = get_image_manager()
|
||||
|
||||
result = UniMessage.text(f"[KonaPH#{puzzle.index_id}] {puzzle.title}")
|
||||
result = result.text(f"\n\n{puzzle.content}")
|
||||
|
||||
if puzzle.img_name:
|
||||
result = result.text("\n\n").image(
|
||||
raw=img_manager.read_puzzle_image(puzzle.img_name)
|
||||
)
|
||||
|
||||
result = result.text(f"\n\n出题者:{get_username(puzzle.author_id)}")
|
||||
|
||||
if with_answer:
|
||||
result = result.text(f"\n\n题目答案:{puzzle.flag}")
|
||||
else:
|
||||
result = result.text("\n\nAt 我或者私聊我「提交答案 答案」来提交你的解答")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_submission_message(
|
||||
puzzle: Puzzle,
|
||||
submission: PuzzleSubmission,
|
||||
daily_puzzle_info: DailyPuzzleInfo | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
获得提交答案的反馈信息
|
||||
"""
|
||||
|
||||
if submission.success:
|
||||
rank = -1
|
||||
if daily_puzzle_info is not None:
|
||||
rank = len(daily_puzzle_info.success_users)
|
||||
return f"🎉 恭喜你答对了!你是今天第 {rank} 个解出来的!"
|
||||
if submission.hint_id >= 0 and (
|
||||
hint := puzzle.hints.get(submission.hint_id)
|
||||
) is not None:
|
||||
if hint.is_checkpoint:
|
||||
hint_msg = "✨ 恭喜!这是本题的中间答案,加油!"
|
||||
else:
|
||||
hint_msg = "🤔 答错啦!请检查你的答案。"
|
||||
return f"{hint_msg}\n\n提示:{hint.message}"
|
||||
return "❌ 答错啦!请检查你的答案。"
|
||||
|
||||
|
||||
def get_daily_report(
|
||||
manager: PuzzleManager,
|
||||
date: datetime.date,
|
||||
) -> str | None:
|
||||
"""
|
||||
获得某日的提交的报告信息
|
||||
"""
|
||||
|
||||
index_id = manager.daily_puzzle_of_date.get(date)
|
||||
if index_id is None:
|
||||
return None
|
||||
info = manager.daily_puzzle[index_id]
|
||||
puzzle = manager.puzzle_data[info.raw_id]
|
||||
|
||||
msg = f"[KonaPH#{puzzle.index_id}] 「{puzzle.title}」解答报告\n\n"
|
||||
if len(info.success_users) == 0:
|
||||
msg += "昨日,无人解出此题 😭😭\n\n"
|
||||
else:
|
||||
msg += f"昨日,共有 {len(info.success_users)} 人解出此题。\n\n"
|
||||
msg += "前五名的解答者:\n\n"
|
||||
us = [(u, d) for u, d in info.success_users.items()]
|
||||
us = sorted(us, key=lambda t: t[1])
|
||||
us = us[:5]
|
||||
for u, _ in us:
|
||||
m = manager.submissions[puzzle.raw_id][u][-1]
|
||||
msg += f"- {get_username(u)} 于 {m.time.strftime('%H:%M')}\n"
|
||||
msg += "\n"
|
||||
msg += f"出题人:{get_username(puzzle.author_id)}"
|
||||
return msg
|
||||
|
||||
|
||||
def get_daily_report_v2(manager: PuzzleManager, gid: int | None = None):
|
||||
p = manager.get_today_puzzle()
|
||||
if p is None:
|
||||
return "今天无题"
|
||||
msg = "==== 今日答题情况 ====\n\n"
|
||||
|
||||
subcount = len(functools.reduce(
|
||||
lambda x, y: x + y,
|
||||
manager.submissions.get(p.raw_id, {}).values(),
|
||||
[],
|
||||
))
|
||||
info = manager.daily_puzzle[p.index_id]
|
||||
|
||||
msg += (
|
||||
f"总体情况:答对 {len(info.success_users)} / "
|
||||
f"参与 {len(info.tried_users)} / "
|
||||
f"提交 {subcount}\n"
|
||||
)
|
||||
|
||||
success_users = sorted(list(info.success_users.items()), key=lambda v: v[1])
|
||||
for u, d in success_users:
|
||||
uname = u
|
||||
if re.match(r"^\d+$", u):
|
||||
uname = get_username(int(u), gid)
|
||||
t = d.strftime("%H:%M")
|
||||
tries = len(manager.submissions[p.raw_id][u])
|
||||
msg += f"\n- {uname} [🎉 {t} 完成 | {tries} 提交]"
|
||||
for u in info.tried_users - set(info.success_users.keys()):
|
||||
uname = u
|
||||
if re.match(r"^\d+$", u):
|
||||
uname = get_username(int(u), gid)
|
||||
tries = len(manager.submissions[p.raw_id][u])
|
||||
checkpoints_touched = len(set((
|
||||
s.hint_id for s in manager.submissions[p.raw_id][u]
|
||||
if (
|
||||
s.hint_id >= 0
|
||||
and s.hint_id in p.hints
|
||||
and p.hints[s.hint_id].is_checkpoint
|
||||
)
|
||||
)))
|
||||
checkpoint_message = ""
|
||||
if checkpoints_touched > 0:
|
||||
checkpoint_message = f" | 🚩 {checkpoints_touched} 记录点"
|
||||
msg += f"\n- {uname} [💦 {tries} 提交{checkpoint_message}]"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def get_puzzle_info_message(manager: PuzzleManager, puzzle: Puzzle) -> UniMessage[Any]:
|
||||
image_manager = get_image_manager()
|
||||
|
||||
status = "✅ 已准备,待发布" if puzzle.ready and not manager.is_puzzle_published(puzzle.raw_id) else \
|
||||
(f"🟢 已发布: #{puzzle.index_id}" if manager.is_puzzle_published(puzzle.raw_id) else "⚙️ 未准备")
|
||||
|
||||
status_suffix = ""
|
||||
if puzzle.raw_id == manager.puzzle_pinned:
|
||||
status_suffix += " | 📌 已被管理员置顶"
|
||||
|
||||
msg = UniMessage.text(
|
||||
f"--- 谜题信息 ---\n"
|
||||
f"Raw ID: {puzzle.raw_id}\n"
|
||||
f"出题者: {get_username(puzzle.author_id)} | {puzzle.author_id}\n"
|
||||
f"创建时间: {puzzle.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"状态: {status}{status_suffix}\n\n"
|
||||
f"标题: {puzzle.title}\n"
|
||||
f"Flag: {puzzle.flag}\n\n"
|
||||
f"{puzzle.content}"
|
||||
)
|
||||
|
||||
if puzzle.img_name:
|
||||
msg = msg.image(raw=image_manager.read_puzzle_image(puzzle.img_name))
|
||||
|
||||
msg = msg.text(f"\n---------\n使用 `konaph ready {puzzle.raw_id}` 完成编辑")
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def get_puzzle_hint_list(puzzle: Puzzle) -> str:
|
||||
msg = f"==== {puzzle.title} 提示与中间答案 ====\n"
|
||||
if len(puzzle.hints) == 0:
|
||||
msg += "\n你没有添加任何中间答案。"
|
||||
return msg
|
||||
for hint_id, hint in puzzle.hints.items():
|
||||
n = {False: "[提示]", True: "[中间答案]"}[hint.is_checkpoint]
|
||||
msg += f"\n{n}[{hint_id}] {hint.pattern}"
|
||||
msg += f"\n {hint.message}"
|
||||
return msg
|
||||
9
konabot/plugins/kona_ph/core/path.py
Normal file
@ -0,0 +1,9 @@
|
||||
from konabot.common.path import DATA_PATH
|
||||
|
||||
KONAPH_BASE = DATA_PATH / "KonaPH"
|
||||
KONAPH_DATA_JSON = KONAPH_BASE / "data.json"
|
||||
KONAPH_IMAGE_BASE = KONAPH_BASE / "imgs"
|
||||
|
||||
# 保证所有文件夹存在
|
||||
KONAPH_BASE.mkdir(exist_ok=True)
|
||||
KONAPH_IMAGE_BASE.mkdir(exist_ok=True)
|
||||
259
konabot/plugins/kona_ph/core/storage.py
Normal file
@ -0,0 +1,259 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import random
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import nanoid
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from konabot.plugins.kona_ph.core.path import KONAPH_DATA_JSON
|
||||
|
||||
|
||||
class PuzzleHint(BaseModel):
|
||||
pattern: str
|
||||
message: str
|
||||
is_checkpoint: bool
|
||||
|
||||
|
||||
class PuzzleSubmission(BaseModel):
|
||||
success: bool
|
||||
flag: str
|
||||
time: datetime.datetime
|
||||
hint_id: int = -1
|
||||
|
||||
|
||||
class Puzzle(BaseModel):
|
||||
raw_id: str
|
||||
"用于给出题者管理的 ID"
|
||||
|
||||
index_id: str
|
||||
"展出的 ID,以展出顺序为准"
|
||||
|
||||
title: str
|
||||
content: str
|
||||
img_name: str
|
||||
author_id: str
|
||||
flag: str
|
||||
|
||||
ready: bool = False
|
||||
created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
|
||||
|
||||
hints: dict[int, PuzzleHint] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def hint_id_max(self) -> int:
|
||||
return max((0, *self.hints.keys()))
|
||||
|
||||
def check_submission(
|
||||
self,
|
||||
submission: str,
|
||||
time: datetime.datetime | None = None,
|
||||
) -> PuzzleSubmission:
|
||||
if time is None:
|
||||
time = datetime.datetime.now()
|
||||
if submission == self.flag:
|
||||
return PuzzleSubmission(
|
||||
success=True,
|
||||
flag=submission,
|
||||
time=time,
|
||||
)
|
||||
for hint_id, hint in self.hints.items():
|
||||
if hint.pattern.startswith('/') and hint.pattern.endswith('/'):
|
||||
if re.match(hint.pattern.strip('/'), submission):
|
||||
return PuzzleSubmission(
|
||||
success=False,
|
||||
flag=submission,
|
||||
time=time,
|
||||
hint_id=hint_id,
|
||||
)
|
||||
else:
|
||||
if hint.pattern == submission:
|
||||
return PuzzleSubmission(
|
||||
success=False,
|
||||
flag=submission,
|
||||
time=time,
|
||||
hint_id=hint_id,
|
||||
)
|
||||
return PuzzleSubmission(
|
||||
success=False,
|
||||
flag=submission,
|
||||
time=time,
|
||||
)
|
||||
|
||||
|
||||
class DailyPuzzleInfo(BaseModel):
|
||||
raw_id: str
|
||||
time: datetime.date
|
||||
tried_users: set[str] = set()
|
||||
success_users: dict[str, datetime.datetime] = {}
|
||||
|
||||
|
||||
class PuzzleSubmissionFeedback(BaseModel):
|
||||
submission: PuzzleSubmission
|
||||
puzzle: Puzzle
|
||||
info: DailyPuzzleInfo | None = None
|
||||
|
||||
|
||||
def get_today_date() -> datetime.date:
|
||||
now = datetime.datetime.now()
|
||||
if now.hour < 8:
|
||||
now -= datetime.timedelta(days=1)
|
||||
return now.date()
|
||||
|
||||
|
||||
class PuzzleManager(BaseModel):
|
||||
puzzle_data: dict[str, Puzzle] = {}
|
||||
|
||||
daily_puzzle: dict[str, DailyPuzzleInfo] = {}
|
||||
daily_puzzle_of_date: dict[datetime.date, str] = {}
|
||||
|
||||
puzzle_pinned: str = ""
|
||||
|
||||
index_id_counter: int = 1
|
||||
submissions: dict[str, dict[str, list[PuzzleSubmission]]] = {}
|
||||
last_checked_date: datetime.date = Field(
|
||||
default_factory=lambda: get_today_date() - datetime.timedelta(days=1)
|
||||
)
|
||||
|
||||
@property
|
||||
def last_publish_date(self):
|
||||
return max(self.daily_puzzle_of_date.keys())
|
||||
|
||||
@property
|
||||
def unpublished_puzzles(self):
|
||||
return set((
|
||||
p.raw_id for p in self.puzzle_data.values()
|
||||
if not self.is_puzzle_published(p.raw_id) and p.ready
|
||||
))
|
||||
|
||||
@property
|
||||
def unready_puzzles(self):
|
||||
return set((
|
||||
p.raw_id for p in self.puzzle_data.values()
|
||||
if not self.is_puzzle_published(p.raw_id) and not p.ready
|
||||
))
|
||||
|
||||
@property
|
||||
def published_puzzles(self):
|
||||
return set((
|
||||
p.raw_id for p in self.puzzle_data.values()
|
||||
if self.is_puzzle_published(p.raw_id)
|
||||
))
|
||||
|
||||
def is_puzzle_published(self, raw_id: str):
|
||||
return raw_id in [i.raw_id for i in self.daily_puzzle.values()]
|
||||
|
||||
def publish_puzzle(self, raw_id: str):
|
||||
assert raw_id in self.puzzle_data
|
||||
|
||||
today = get_today_date()
|
||||
|
||||
p = self.puzzle_data[raw_id]
|
||||
p.index_id = str(self.index_id_counter)
|
||||
p.ready = True
|
||||
self.puzzle_pinned = ""
|
||||
self.last_checked_date = today
|
||||
self.daily_puzzle[p.index_id] = DailyPuzzleInfo(
|
||||
raw_id=raw_id,
|
||||
time=today,
|
||||
)
|
||||
self.daily_puzzle_of_date[today] = p.index_id
|
||||
|
||||
self.index_id_counter += 1
|
||||
|
||||
def admin_pin_puzzle(self, raw_id: str):
|
||||
if raw_id in self.puzzle_data:
|
||||
self.puzzle_pinned = raw_id
|
||||
else:
|
||||
self.puzzle_pinned = ""
|
||||
|
||||
def get_today_puzzle(self, strong: bool = False) -> Puzzle | None:
|
||||
today = get_today_date()
|
||||
if today in self.daily_puzzle_of_date:
|
||||
index_id = self.daily_puzzle_of_date[today]
|
||||
info = self.daily_puzzle[index_id]
|
||||
return self.puzzle_data[info.raw_id]
|
||||
if today == self.last_checked_date and not strong:
|
||||
return
|
||||
self.last_checked_date = today
|
||||
if self.puzzle_pinned and self.puzzle_pinned in self.puzzle_data:
|
||||
d = self.puzzle_pinned
|
||||
self.publish_puzzle(d)
|
||||
self.puzzle_pinned = ""
|
||||
return self.puzzle_data[d]
|
||||
elif len(self.unpublished_puzzles) > 0:
|
||||
d = random.choice(list(self.unpublished_puzzles))
|
||||
self.publish_puzzle(d)
|
||||
return self.puzzle_data[d]
|
||||
|
||||
def get_today_info(self) -> DailyPuzzleInfo | None:
|
||||
p = self.get_today_puzzle()
|
||||
if p is None:
|
||||
return
|
||||
return self.daily_puzzle[p.index_id]
|
||||
|
||||
def submit(self, user: str, flag: str) -> PuzzleSubmissionFeedback | str:
|
||||
p = self.get_today_puzzle()
|
||||
d = self.get_today_info()
|
||||
now = datetime.datetime.now()
|
||||
if p is None or d is None:
|
||||
return "今天没有题哦,改天再来吧!"
|
||||
if user in d.success_users:
|
||||
return "你今天已经答对过啦!不用重复提交哦!"
|
||||
d.tried_users.add(user)
|
||||
result = p.check_submission(flag, now)
|
||||
self.submissions.setdefault(p.raw_id, {}).setdefault(user, []).append(result)
|
||||
if result.success:
|
||||
d.success_users[user] = now
|
||||
return PuzzleSubmissionFeedback(
|
||||
submission=result,
|
||||
puzzle=p,
|
||||
info=d,
|
||||
)
|
||||
|
||||
def admin_create_puzzle(self, user: str):
|
||||
p = Puzzle(
|
||||
raw_id=nanoid.generate(
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz",
|
||||
12,
|
||||
),
|
||||
index_id="",
|
||||
title="示例标题",
|
||||
content="题目的内容填写内容",
|
||||
img_name="",
|
||||
author_id=user,
|
||||
flag="konaph{this_is_a_flag}",
|
||||
ready=False,
|
||||
)
|
||||
self.puzzle_data[p.raw_id] = p
|
||||
return p
|
||||
|
||||
def get_puzzles_of_user(self, user: str):
|
||||
return sorted([
|
||||
p for p in self.puzzle_data.values()
|
||||
if p.author_id == user
|
||||
], key=lambda p: p.created_at, reverse=True)
|
||||
|
||||
|
||||
lock = asyncio.Lock()
|
||||
|
||||
|
||||
def read_data():
|
||||
try:
|
||||
data_raw = KONAPH_DATA_JSON.read_text("utf-8")
|
||||
return PuzzleManager.model_validate_json(data_raw)
|
||||
except (FileNotFoundError, ValidationError):
|
||||
return PuzzleManager()
|
||||
|
||||
|
||||
def write_data(data: PuzzleManager):
|
||||
KONAPH_DATA_JSON.write_text(data.model_dump_json(), "utf-8")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def puzzle_manager():
|
||||
async with lock:
|
||||
data = read_data()
|
||||
yield data
|
||||
write_data(data)
|
||||
464
konabot/plugins/kona_ph/manager.py
Normal file
@ -0,0 +1,464 @@
|
||||
import datetime
|
||||
from math import ceil
|
||||
|
||||
from nonebot import get_plugin_config
|
||||
from nonebot_plugin_alconna import (Alconna, Args, Image, Option, Query,
|
||||
Subcommand, SubcommandResult, UniMessage,
|
||||
on_alconna)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
from konabot.common.nb.extract_image import download_image_bytes
|
||||
from konabot.common.nb.qq_broadcast import qq_broadcast
|
||||
from konabot.common.username import get_username
|
||||
from konabot.plugins.kona_ph.core.image import get_image_manager
|
||||
from konabot.plugins.kona_ph.core.message import (get_puzzle_description, get_puzzle_hint_list,
|
||||
get_puzzle_info_message,
|
||||
get_submission_message)
|
||||
from konabot.plugins.kona_ph.core.storage import (Puzzle, PuzzleHint, PuzzleManager,
|
||||
get_today_date,
|
||||
puzzle_manager)
|
||||
|
||||
PUZZLE_PAGE_SIZE = 10
|
||||
|
||||
|
||||
class PuzzleConfig(BaseModel):
|
||||
plugin_puzzle_manager: list[str] = []
|
||||
plugin_puzzle_admin: list[str] = []
|
||||
plugin_puzzle_playgroup: list[str] = []
|
||||
|
||||
|
||||
config = get_plugin_config(PuzzleConfig)
|
||||
|
||||
|
||||
def is_puzzle_manager(target: DepLongTaskTarget):
|
||||
return target.target_id in config.plugin_puzzle_manager or is_puzzle_admin(target)
|
||||
|
||||
|
||||
def is_puzzle_admin(target: DepLongTaskTarget):
|
||||
return target.target_id in config.plugin_puzzle_admin
|
||||
|
||||
|
||||
def check_puzzle(manager: PuzzleManager, target: DepLongTaskTarget, raw_id: str) -> Puzzle:
|
||||
if raw_id not in manager.puzzle_data:
|
||||
raise BotExceptionMessage("没有这个谜题")
|
||||
puzzle = manager.puzzle_data[raw_id]
|
||||
if is_puzzle_admin(target):
|
||||
return puzzle
|
||||
if target.target_id != puzzle.author_id:
|
||||
raise BotExceptionMessage("你没有权限查看或编辑这个谜题")
|
||||
return puzzle
|
||||
|
||||
|
||||
def create_admin_commands():
|
||||
cmd_admin = on_alconna(
|
||||
Alconna(
|
||||
"konaph",
|
||||
Subcommand("create", dest="create"),
|
||||
Subcommand("ready", Args["raw_id", str], dest="ready"),
|
||||
Subcommand("unready", Args["raw_id", str], dest="unready"),
|
||||
Subcommand("info", Args["raw_id", str], dest="info"),
|
||||
Subcommand("my", Args["page?", int], dest="my"),
|
||||
Subcommand("all", Option("--ready", alias=["-r"]), Args["page?", int], dest="all"),
|
||||
Subcommand("pin", Args["raw_id?", str], dest="pin"),
|
||||
Subcommand("unpin", dest="unpin"),
|
||||
Subcommand(
|
||||
"modify",
|
||||
Args["raw_id?", str],
|
||||
Option("--title", Args["title", str], alias=["-t"]),
|
||||
Option("--description", Args["description", str], alias=["-d"]),
|
||||
Option("--image", Args["image?", Image], alias=["-i"]),
|
||||
Option("--flag", Args["flag", str], alias=["-f"]),
|
||||
Option("--remove-image"),
|
||||
dest="modify",
|
||||
),
|
||||
Subcommand("publish", Args["raw_id?", str], dest="publish"),
|
||||
Subcommand("preview", Args["raw_id", str], dest="preview"),
|
||||
Subcommand("get-submits", Args["raw_id", str], dest="get-submits"),
|
||||
Subcommand(
|
||||
"test",
|
||||
Args["raw_id", str],
|
||||
Args["submission", str],
|
||||
dest="test",
|
||||
),
|
||||
Subcommand(
|
||||
"hint",
|
||||
Subcommand(
|
||||
"add",
|
||||
Args["raw_id", str],
|
||||
Args["pattern", str],
|
||||
Args["message", str],
|
||||
dest="add",
|
||||
),
|
||||
Subcommand(
|
||||
"list",
|
||||
Args["raw_id", str],
|
||||
Args["page?", int],
|
||||
dest="list",
|
||||
),
|
||||
Subcommand(
|
||||
"modify",
|
||||
Args["raw_id", str],
|
||||
Args["hint_id", int],
|
||||
Option("--pattern", Args["pattern", str], alias=["-p"]),
|
||||
Option("--message", Args["message", str], alias=["-m"]),
|
||||
Option("--checkpoint", Args["is_checkpoint", bool], alias=["-c"]),
|
||||
dest="modify",
|
||||
),
|
||||
Subcommand(
|
||||
"delete",
|
||||
Args["raw_id", str],
|
||||
Args["hint_id", int],
|
||||
dest="delete",
|
||||
),
|
||||
dest="hint",
|
||||
),
|
||||
),
|
||||
rule=is_puzzle_manager,
|
||||
)
|
||||
|
||||
@cmd_admin.assign("$main")
|
||||
async def _(target: DepLongTaskTarget):
|
||||
msg = UniMessage.text("==== [KonaPH] 指令一览 ====\n\n")
|
||||
msg = msg.text("konaph create - 创建一个新的谜题\n")
|
||||
msg = msg.text("konaph ready <id> - 准备好一道谜题\n")
|
||||
msg = msg.text("konaph unready <id> - 取消准备一道谜题\n")
|
||||
msg = msg.text("konaph info <id> - 查看谜题\n")
|
||||
msg = msg.text("konaph my <page?> - 查看我的谜题列表\n")
|
||||
msg = msg.text("konaph modify - 查看如何修改谜题信息\n")
|
||||
msg = msg.text("konaph preview <id> - 预览一个题目的效果,不会展示答案\n")
|
||||
msg = msg.text("konaph get-submits <id> - 获得题目的提交记录\n")
|
||||
msg = msg.text("konaph test <id> <answer> - 尝试提交一个答案,看回答的效果\n")
|
||||
msg = msg.text("konaph hint - 查看如何编辑题目的中间答案\n")
|
||||
|
||||
if is_puzzle_admin(target):
|
||||
msg = msg.text("konaph all [--ready] <page?> - 查看所有谜题\n")
|
||||
msg = msg.text("konaph pin - 查看当前置顶谜题\n")
|
||||
msg = msg.text("konaph pin <id> - 置顶一个谜题\n")
|
||||
msg = msg.text("konaph unpin - 取消置顶所有谜题\n")
|
||||
msg = msg.text("konaph publish <id?> - 强制发题")
|
||||
|
||||
await target.send_message(msg)
|
||||
|
||||
@cmd_admin.assign("create")
|
||||
async def _(target: DepLongTaskTarget):
|
||||
async with puzzle_manager() as manager:
|
||||
puzzle = manager.admin_create_puzzle(target.target_id)
|
||||
await target.send_message(UniMessage.text(
|
||||
f"✨ 创建好啦!谜题 ID 为 {puzzle.raw_id}\n\n"
|
||||
f"- 输入 `konaph info {puzzle.raw_id}` 获得谜题的信息\n"
|
||||
f"- 输入 `konaph my` 查看你创建的谜题\n"
|
||||
f"- 输入 `konaph modify` 查看更改谜题的方法"
|
||||
))
|
||||
|
||||
@cmd_admin.assign("ready")
|
||||
async def _(raw_id: str, target: DepLongTaskTarget):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
if p.ready:
|
||||
return await target.send_message(UniMessage.text(
|
||||
"题目早就准备好啦!"
|
||||
))
|
||||
p.ready = True
|
||||
await target.send_message(UniMessage.text(
|
||||
f"谜题「{p.title}」已经准备就绪!"
|
||||
))
|
||||
|
||||
@cmd_admin.assign("unready")
|
||||
async def _(raw_id: str, target: DepLongTaskTarget):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
if not p.ready:
|
||||
return await target.send_message(UniMessage.text(
|
||||
f"谜题「{p.title}」已经是未取消状态了!"
|
||||
))
|
||||
if manager.is_puzzle_published(p.raw_id):
|
||||
return await target.send_message(UniMessage.text(
|
||||
"已发布的谜题不能取消准备状态!"
|
||||
))
|
||||
|
||||
p.ready = False
|
||||
await target.send_message(UniMessage.text(
|
||||
f"谜题「{p.title}」已经取消准备!"
|
||||
))
|
||||
|
||||
@cmd_admin.assign("info")
|
||||
async def _(raw_id: str, target: DepLongTaskTarget):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
await target.send_message(get_puzzle_info_message(manager, p))
|
||||
|
||||
@cmd_admin.assign("my")
|
||||
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||
async with puzzle_manager() as manager:
|
||||
puzzles = manager.get_puzzles_of_user(target.target_id)
|
||||
if len(puzzles) == 0:
|
||||
return await target.send_message(UniMessage.text(
|
||||
"你没有谜题哦,使用 `konaph create` 创建一个吧!"
|
||||
))
|
||||
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
||||
if page <= 0 or page > count_pages:
|
||||
return await target.send_message(UniMessage.text(
|
||||
f"页数只有 1 ~ {count_pages} 啦!"
|
||||
))
|
||||
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
|
||||
message = UniMessage.text("==== 我的谜题 ====\n\n")
|
||||
for p in puzzles:
|
||||
message = message.text("- ")
|
||||
if manager.puzzle_pinned == p.raw_id:
|
||||
message = message.text("[📌]")
|
||||
if manager.is_puzzle_published(p.raw_id):
|
||||
message = message.text(f"[✨][#{p.index_id}] ")
|
||||
elif p.ready:
|
||||
message = message.text("[✅] ")
|
||||
else:
|
||||
message = message.text("[⚙️] ")
|
||||
message = message.text(f"{p.title} ({p.raw_id})")
|
||||
message = message.text("\n")
|
||||
message = message.text(f"\n==== 第 {page} 页,共 {count_pages} 页 ====")
|
||||
await target.send_message(message)
|
||||
|
||||
@cmd_admin.assign("all")
|
||||
async def _(target: DepLongTaskTarget, ready: Query[bool] = Query("all.ready"), page: int = 1):
|
||||
if not is_puzzle_admin(target):
|
||||
return await target.send_message(UniMessage.text(
|
||||
"你没有权限使用该指令"
|
||||
))
|
||||
async with puzzle_manager() as manager:
|
||||
puzzles = [*manager.puzzle_data.values()]
|
||||
if ready.available:
|
||||
puzzles = [p for p in puzzles if p.ready]
|
||||
puzzles = sorted(puzzles, key=lambda p: p.created_at, reverse=True)
|
||||
count_pages = ceil(len(puzzles) / PUZZLE_PAGE_SIZE)
|
||||
if page <= 0 or page > count_pages:
|
||||
return await target.send_message(UniMessage.text(
|
||||
f"页数只有 1 ~ {count_pages} 啦!"
|
||||
))
|
||||
puzzles = puzzles[(page - 1) * PUZZLE_PAGE_SIZE: page * PUZZLE_PAGE_SIZE]
|
||||
message = UniMessage.text("==== 所有谜题 ====\n\n")
|
||||
for p in puzzles:
|
||||
message = message.text("- ")
|
||||
if p.raw_id == manager.puzzle_pinned:
|
||||
message = message.text("[📌]")
|
||||
if manager.is_puzzle_published(p.raw_id):
|
||||
message = message.text(f"[✨][#{p.index_id}] ")
|
||||
elif p.ready:
|
||||
message = message.text("[✅] ")
|
||||
else:
|
||||
message = message.text("[⚙️] ")
|
||||
message = message.text(f"{p.title} ({p.raw_id} by {p.author_id})")
|
||||
message = message.text("\n")
|
||||
message = message.text(f"\n==== 第 {page} 页,共 {count_pages} 页 ====")
|
||||
await target.send_message(message)
|
||||
|
||||
@cmd_admin.assign("pin")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str = ""):
|
||||
if not is_puzzle_admin(target):
|
||||
return await target.send_message(UniMessage.text(
|
||||
"你没有权限使用该指令"
|
||||
))
|
||||
|
||||
async with puzzle_manager() as manager:
|
||||
if raw_id == "":
|
||||
if manager.puzzle_pinned:
|
||||
return await target.send_message(UniMessage.text(
|
||||
f"被 Pin 的谜题 ID = {manager.puzzle_pinned}"
|
||||
))
|
||||
return await target.send_message("没有置顶谜题")
|
||||
if raw_id not in manager.unpublished_puzzles:
|
||||
return await target.send_message(UniMessage.text(
|
||||
"这个谜题已经发布了,或者还没准备好,或者不存在"
|
||||
))
|
||||
manager.admin_pin_puzzle(raw_id)
|
||||
return await target.send_message(f"已置顶谜题 {raw_id}")
|
||||
|
||||
@cmd_admin.assign("unpin")
|
||||
async def _(target: DepLongTaskTarget):
|
||||
if not is_puzzle_admin(target):
|
||||
return await target.send_message(UniMessage.text(
|
||||
"你没有权限使用该指令"
|
||||
))
|
||||
async with puzzle_manager() as manager:
|
||||
manager.admin_pin_puzzle("")
|
||||
return await target.send_message("已取消所有置顶")
|
||||
|
||||
@cmd_admin.assign("modify")
|
||||
async def _(
|
||||
target: DepLongTaskTarget,
|
||||
raw_id: str = "",
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
flag: str | None = None,
|
||||
image: Image | None = None,
|
||||
remove_image: Query[bool] = Query("modify.remove-image"),
|
||||
):
|
||||
if raw_id == "":
|
||||
return await target.send_message(
|
||||
"konaph modify <raw_id> - 修改一个谜题\n\n"
|
||||
"支持的参数:\n"
|
||||
" --title <str> 标题\n"
|
||||
" --description <str> 题目详情描述(用直引号包裹以支持多行)\n"
|
||||
" --flag <str> flag,也就是题目的答案\n"
|
||||
" --image <图片> 图片\n"
|
||||
" --remove-image 删除图片"
|
||||
)
|
||||
image_manager = get_image_manager()
|
||||
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
if title is not None:
|
||||
p.title = title
|
||||
if description is not None:
|
||||
p.content = description
|
||||
if flag is not None:
|
||||
p.flag = flag.strip()
|
||||
if flag.strip() != flag:
|
||||
await target.send_message(
|
||||
"⚠️ 注意:你输入的 Flag 含有开头或结尾的空格,已经帮你去除"
|
||||
)
|
||||
if image is not None and image.url is not None:
|
||||
b = await download_image_bytes(image.url)
|
||||
image_manager.remove_puzzle_image(p.img_name)
|
||||
p.img_name = image_manager.upload_puzzle_image(b.unwrap())
|
||||
elif remove_image.available:
|
||||
image_manager.remove_puzzle_image(p.img_name)
|
||||
|
||||
info2 = get_puzzle_info_message(manager, p)
|
||||
|
||||
return await target.send_message("修改好啦!看看效果:\n\n" + info2)
|
||||
|
||||
@cmd_admin.assign("publish")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str | None = None):
|
||||
if not is_puzzle_admin(target):
|
||||
return await target.send_message(UniMessage.text(
|
||||
"你没有权限使用该指令"
|
||||
))
|
||||
today = get_today_date()
|
||||
async with puzzle_manager() as manager:
|
||||
if today in manager.daily_puzzle_of_date:
|
||||
return await target.send_message("今日已经有题了哦")
|
||||
manager.last_checked_date = today - datetime.timedelta(days=-1)
|
||||
if raw_id is not None:
|
||||
manager.admin_pin_puzzle(raw_id)
|
||||
p = manager.get_today_puzzle(strong=True)
|
||||
if p is None:
|
||||
return await target.send_message("上架失败了orz,可能是没题了")
|
||||
await qq_broadcast(config.plugin_puzzle_playgroup, get_puzzle_description(p))
|
||||
return await target.send_message("Ok!")
|
||||
|
||||
@cmd_admin.assign("preview")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
return await target.send_message(get_puzzle_description(p))
|
||||
|
||||
@cmd_admin.assign("get-submits")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str):
|
||||
async with puzzle_manager() as manager:
|
||||
puzzle = manager.puzzle_data.get(raw_id)
|
||||
if puzzle is None:
|
||||
return await target.send_message("没有这个谜题")
|
||||
if not is_puzzle_admin(target) and target.target_id != puzzle.author_id:
|
||||
return await target.send_message("你没有权限预览这个谜题")
|
||||
|
||||
msg = UniMessage.text(f"==== {puzzle.title} 提交记录 ====\n\n")
|
||||
submits = manager.submissions.get(raw_id, {})
|
||||
for uid, ls in submits.items():
|
||||
s = ', '.join((i.flag for i in ls))
|
||||
msg = msg.text(f"- {get_username(uid)}:{s}\n")
|
||||
return await target.send_message(msg)
|
||||
|
||||
@cmd_admin.assign("test")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str, submission: str):
|
||||
"""
|
||||
测试一道谜题的回答,并给出结果
|
||||
"""
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
result = p.check_submission(submission)
|
||||
msg = get_submission_message(p, result)
|
||||
return await target.send_message("[测试提交] " + msg)
|
||||
|
||||
@cmd_admin.assign("subcommands.hint")
|
||||
async def _(target: DepLongTaskTarget, subcommands: Query[SubcommandResult] = Query("subcommands.hint")):
|
||||
if len(subcommands.result.subcommands) > 0:
|
||||
return
|
||||
return await target.send_message(
|
||||
UniMessage.text("==== 提示/中间答案编辑器 ====\n\n")
|
||||
.text("- konaph hint list <id>\n - 查看某道题的所有提示 / 中间答案\n")
|
||||
.text("- konaph hint add <id> <pattern> <hint>\n - 添加一个提示 / 中间答案\n")
|
||||
.text("- konaph hint modify <id> <hint_id>\n")
|
||||
.text(" - --pattern <pattern>\n - 更改匹配规则\n")
|
||||
.text(" - --message <message>\n - 更改提示文本\n")
|
||||
.text(" - --checkpoint [True|False]\n - 更改是否为中间答案\n")
|
||||
.text("- konaph hint delete <id> <hint_id>\n - 删除一个提示 / 中间答案\n")
|
||||
.text("\n更多关于 pattern 和中间答案的信息,请见 man:中间答案(7)")
|
||||
)
|
||||
|
||||
@cmd_admin.assign("subcommands.hint.add")
|
||||
async def _(
|
||||
target: DepLongTaskTarget,
|
||||
raw_id: str,
|
||||
pattern: str,
|
||||
message: str,
|
||||
):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
p.hints[p.hint_id_max + 1] = PuzzleHint(
|
||||
pattern=pattern,
|
||||
message=message,
|
||||
is_checkpoint=False,
|
||||
)
|
||||
await target.send_message("创建成功!\n\n" + get_puzzle_hint_list(p))
|
||||
|
||||
@cmd_admin.assign("subcommands.hint.list")
|
||||
async def _(
|
||||
target: DepLongTaskTarget,
|
||||
raw_id: str,
|
||||
):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
await target.send_message(get_puzzle_hint_list(p))
|
||||
|
||||
@cmd_admin.assign("subcommands.hint.modify")
|
||||
async def _(
|
||||
target: DepLongTaskTarget,
|
||||
raw_id: str,
|
||||
hint_id: int,
|
||||
pattern: str | None = None,
|
||||
message: str | None = None,
|
||||
is_checkpoint: bool | None = None,
|
||||
):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
if hint_id not in p.hints:
|
||||
raise BotExceptionMessage(
|
||||
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
|
||||
)
|
||||
hint = p.hints[hint_id]
|
||||
if pattern is not None:
|
||||
hint.pattern = pattern
|
||||
if message is not None:
|
||||
hint.message = message
|
||||
if is_checkpoint is not None:
|
||||
hint.is_checkpoint = is_checkpoint
|
||||
await target.send_message("更改成功!\n\n" + get_puzzle_hint_list(p))
|
||||
|
||||
@cmd_admin.assign("subcommands.hint.delete")
|
||||
async def _(
|
||||
target: DepLongTaskTarget,
|
||||
raw_id: str,
|
||||
hint_id: int,
|
||||
):
|
||||
async with puzzle_manager() as manager:
|
||||
p = check_puzzle(manager, target, raw_id)
|
||||
if hint_id not in p.hints:
|
||||
raise BotExceptionMessage(
|
||||
f"没有这个 hint_id。请使用 konaph hint list {raw_id} 了解 hint 清单"
|
||||
)
|
||||
del p.hints[hint_id]
|
||||
await target.send_message("删除成功!\n\n" + get_puzzle_hint_list(p))
|
||||
|
||||
|
||||
return cmd_admin
|
||||
40
konabot/plugins/llm_test.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""
|
||||
肥肠危险注意:本文件仅用于开发环境测试 LLM 模块能否正常工作!
|
||||
|
||||
请不要在生产环境启用它!
|
||||
"""
|
||||
|
||||
import nonebot
|
||||
from nonebot_plugin_alconna import Alconna, Args, on_alconna
|
||||
from pydantic import BaseModel
|
||||
|
||||
from konabot.common.llm import get_llm
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
|
||||
|
||||
class LLMTestConfig(BaseModel):
|
||||
debug_enable_llm_test: bool = False
|
||||
|
||||
|
||||
config = nonebot.get_plugin_config(LLMTestConfig)
|
||||
|
||||
|
||||
if config.debug_enable_llm_test:
|
||||
cmd = on_alconna(Alconna(
|
||||
"debug-ask-llm",
|
||||
Args["prompt", str],
|
||||
))
|
||||
|
||||
@cmd.handle()
|
||||
async def _(prompt: str, target: DepLongTaskTarget):
|
||||
llm = get_llm()
|
||||
msg = await llm.chat(
|
||||
[
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
timeout=None,
|
||||
max_tokens=1024,
|
||||
)
|
||||
content = msg.content or ""
|
||||
await target.send_message(content)
|
||||
|
||||
47
konabot/plugins/longtask_core.py
Normal file
@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
|
||||
# import datetime
|
||||
from loguru import logger
|
||||
import nonebot
|
||||
|
||||
# from nonebot_plugin_alconna import UniMessage
|
||||
from konabot.common.longtask import (
|
||||
# DepLongTaskTarget,
|
||||
# LongTask,
|
||||
# create_longtask,
|
||||
# handle_long_task,
|
||||
init_longtask,
|
||||
)
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
INIT_FLAG = {"flag": False}
|
||||
|
||||
|
||||
@driver.on_bot_connect
|
||||
async def _():
|
||||
if INIT_FLAG["flag"]:
|
||||
return
|
||||
INIT_FLAG["flag"] = True
|
||||
logger.info("有 Bot 连接,等待 5 秒后初始化 LongTask 模块")
|
||||
await asyncio.sleep(5)
|
||||
await init_longtask()
|
||||
logger.info("LongTask 初始化完成")
|
||||
|
||||
|
||||
# cmd1 = nonebot.on_command("test114")
|
||||
#
|
||||
#
|
||||
# @handle_long_task("test_callback_001")
|
||||
# async def _(lt: LongTask):
|
||||
# await lt.target.send_message(UniMessage().text("Hello, world!"), at=True)
|
||||
#
|
||||
#
|
||||
# @cmd1.handle()
|
||||
# async def _(target: DepLongTaskTarget):
|
||||
# await create_longtask(
|
||||
# handler="test_callback_001",
|
||||
# data={},
|
||||
# target=target,
|
||||
# deadline=datetime.datetime.now() + datetime.timedelta(seconds=20),
|
||||
# )
|
||||
106
konabot/plugins/man/__init__.py
Normal file
@ -0,0 +1,106 @@
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
import nonebot.adapters
|
||||
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
|
||||
from konabot.plugins.markdown.core import MarkDownCore
|
||||
|
||||
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(
|
||||
ord('0') <= ord(c) <= ord('9')
|
||||
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, 7}
|
||||
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, 7}
|
||||
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')
|
||||
# await man.send(UniMessage().text(mans_msg))
|
||||
img = await MarkDownCore.render_markdown(mans_msg)
|
||||
await man.send(UniMessage.image(raw=img))
|
||||
|
||||
|
||||
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
||||
|
||||
@help_deprecated.handle()
|
||||
async def _():
|
||||
await help_deprecated.send('你可以使用 man 指令来查询此方 BOT 的帮助')
|
||||
72
konabot/plugins/markdown/__init__.py
Normal file
@ -0,0 +1,72 @@
|
||||
from loguru import logger
|
||||
import nonebot
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import (
|
||||
UniMessage,
|
||||
UniMsg
|
||||
)
|
||||
|
||||
from konabot.plugins.markdown.core import MarkDownCore
|
||||
|
||||
def is_markdown_mentioned(msg: UniMsg) -> bool:
|
||||
txt = msg.extract_plain_text()
|
||||
if "markdown" not in txt[:8] and "md" not in txt[:2]:
|
||||
return False
|
||||
return True
|
||||
|
||||
evt = nonebot.on_message(rule=is_markdown_mentioned)
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = msg.extract_plain_text()
|
||||
else:
|
||||
content = msg.extract_plain_text()
|
||||
|
||||
logger.debug(f"Received markdown command with content: {content}")
|
||||
if "md" in content[:2]:
|
||||
message = content.replace("md", "", 1).strip()
|
||||
else:
|
||||
message = content.replace("markdown", "", 1).strip()
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(len(message) == 0):
|
||||
if event.reply:
|
||||
message = event.reply.message.extract_plain_text()
|
||||
else:
|
||||
return
|
||||
logger.debug(f"Markdown content to render: {message}")
|
||||
|
||||
out = await MarkDownCore.render_markdown(message, theme="dark")
|
||||
|
||||
await evt.send(await UniMessage().image(raw=out).export())
|
||||
|
||||
|
||||
def is_latex_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
|
||||
txt = msg.extract_plain_text()
|
||||
if "latex" not in txt[:5]:
|
||||
return False
|
||||
return True
|
||||
|
||||
evt = nonebot.on_message(rule=is_latex_mentioned)
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = msg.extract_plain_text()
|
||||
else:
|
||||
content = msg.extract_plain_text()
|
||||
|
||||
logger.debug(f"Received markdown command with content: {content}")
|
||||
message = content.replace("latex", "", 1).strip()
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(len(message) == 0):
|
||||
if event.reply:
|
||||
message = event.reply.message.extract_plain_text()
|
||||
else:
|
||||
return
|
||||
logger.debug(f"Latex content to render: {message}")
|
||||
|
||||
out = await MarkDownCore.render_latex(message, theme="dark")
|
||||
|
||||
await evt.send(await UniMessage().image(raw=out).export())
|
||||
57
konabot/plugins/markdown/core.py
Normal file
@ -0,0 +1,57 @@
|
||||
from loguru import logger
|
||||
from playwright.async_api import ConsoleMessage, Page
|
||||
|
||||
from konabot.common.web_render import konaweb
|
||||
from konabot.common.web_render.core import WebRenderer
|
||||
|
||||
class MarkDownCore:
|
||||
@staticmethod
|
||||
async def render_markdown(markdown_text: str, theme: str = "dark", params: dict = {}) -> bytes:
|
||||
async def page_function(page: Page):
|
||||
await page.emulate_media(color_scheme=theme)
|
||||
|
||||
await page.locator('textarea[name=content]').fill(markdown_text)
|
||||
await page.locator('#button').click()
|
||||
|
||||
# 等待 checkState 函数加载完成
|
||||
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
|
||||
# 访问 checkState 函数,确保渲染完成
|
||||
await page.wait_for_function("checkState() === true", timeout=1000)
|
||||
|
||||
out = await WebRenderer.render_with_persistent_page(
|
||||
"markdown_renderer",
|
||||
konaweb('markdown'),
|
||||
target='#main',
|
||||
other_function=page_function,
|
||||
params=params
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
async def render_latex(text: str, theme: str = "dark") -> bytes:
|
||||
params = {
|
||||
"size": "2.5em",
|
||||
}
|
||||
async def page_function(page: Page):
|
||||
await page.emulate_media(color_scheme=theme)
|
||||
|
||||
page.wait_for_selector('textarea[name=content]')
|
||||
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
|
||||
page.wait_for_selector('#button')
|
||||
await page.locator('#button').click()
|
||||
|
||||
# 等待 checkState 函数加载完成
|
||||
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
|
||||
# 访问 checkState 函数,确保渲染完成
|
||||
await page.wait_for_function("checkState() === true", timeout=10000)
|
||||
|
||||
out = await WebRenderer.render_with_persistent_page(
|
||||
"latex_renderer",
|
||||
konaweb('latex'),
|
||||
target='#main',
|
||||
other_function=page_function,
|
||||
params=params
|
||||
)
|
||||
|
||||
return out
|
||||