Compare commits

...

67 Commits

Author SHA1 Message Date
94db34037b Merge pull request 'Enhancement: 为 man 和 textfx 指令添加图片渲染和文本 fallback' (#54) from enhancement/man-and-textfx into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #54
2026-02-25 16:26:13 +08:00
df409a13a9 把 timeout 调长一点 2026-02-25 16:24:19 +08:00
34175e8c17 添加错误捕获范围,调整日志注入参数方式 2026-02-25 16:20:44 +08:00
c66576e12b Merge pull request 'Feature: 创建启动通知' (#53) from feature/startup-notification into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #53
2026-02-25 16:13:01 +08:00
91769f93ae 添加渲染错误信息为图片 2026-02-25 16:11:23 +08:00
27841b8422 添加 man 指令的渲染 Fallback 2026-02-25 16:11:11 +08:00
48282ceb6c 添加启动通知 2026-02-25 15:08:23 +08:00
00c0202720 Merge pull request 'Feature: 支持响应更多类型的喵' (#52) from feature/nya-more into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #52
2026-02-25 13:57:36 +08:00
3ddf81e7de 修复变量名遮蔽问题 2026-02-25 13:52:25 +08:00
ba15841836 修复缺少「喵」字匹配的问题 2026-02-25 13:49:15 +08:00
014e9c9a71 创建更多喵的响应 2026-02-25 13:40:45 +08:00
32cabc9452 Merge pull request '添加节气报告功能' (#51) from feature/24-solar-terms-notification into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #51
2026-02-21 23:54:24 +08:00
19e83dea01 考虑循环边界条件的风险 2026-02-21 23:51:03 +08:00
9210f85300 让节气静态内置而不是实时 LLM 生成 2026-02-21 23:45:01 +08:00
74052594c3 添加节气查询指令 2026-02-21 23:36:42 +08:00
31ad8dac3e 添加节气报告功能 2026-02-21 22:43:09 +08:00
c46b88060b 宾几人功能调整;bilibili fetch 调整
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 17:57:00 +08:00
02018cd11d 添加宾几人功能
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-18 16:43:35 +08:00
d4cde42bdc Vibe Coding: textfx 若干 issue 更新
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-16 19:36:24 +08:00
58ff8f02da 更新 Celeste Classic 功能
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-02 19:06:24 +08:00
b32ddcaf38 调整 PPI
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-18 12:45:58 +08:00
1eb7e62cfe 添加 Typst 支持
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-18 12:25:17 +08:00
c44e29a907 添加 AI
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 23:12:28 +08:00
24457ff7cd 添加文档
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 22:21:04 +08:00
0d36bea3ca morse
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:49:33 +08:00
bf8504d432 忘记加 sort 了
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:44:10 +08:00
16a55ae69a 添加好多好多 crypto
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:39:45 +08:00
3adbd38d65 fix
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-09 23:42:34 +08:00
420630e35c 添加文本处理功能
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-09 23:39:13 +08:00
36a564547c 不回复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-31 15:21:18 +08:00
eb8bf16346 能不能给此方bot加上这个 by 蜡笔
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-31 15:02:08 +08:00
67884f7133 甲骨文跳过错误修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-30 22:25:50 +08:00
f18d94670e 二简字
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-29 22:11:27 +08:00
6e86a6987f 错误提示
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 21:15:22 +08:00
9c9496efbd new
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 21:13:21 +08:00
770d7567fb 甲骨文
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 16:13:55 +08:00
7026337a43 甲骨文 2025-12-16 15:40:18 +08:00
ef617e1c85 删除「黑白」的文档
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 18:52:51 +08:00
bd71a8d75f 黑白子说要 at 才能用太麻烦了
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 18:51:27 +08:00
605407549b 阴影透明度、颜色读取修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 20:35:13 +08:00
5e01e086f2 形状描边修复 2025-12-13 20:22:13 +08:00
1f887aeaf6 设置遮罩
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 18:37:15 +08:00
5de4b72a6b 大量滤镜转为RGBA以避免问题
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 18:34:15 +08:00
1861cd4f1a 阴影修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-13 18:25:43 +08:00
9148073095 完善形状描边,新增文本图层、空白图层生成
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 22:45:33 +08:00
ef3404b096 添加 shapely 库
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 19:34:13 +08:00
14feae943e 设置通道
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-10 19:29:30 +08:00
1d763dfc3c 设置通道 2025-12-10 19:28:34 +08:00
a829f035b3 new fx
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 17:22:12 +08:00
9904653cc6 new fx 2025-12-10 17:17:52 +08:00
de04fcbec1 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-09 20:33:20 +08:00
70e3565e44 文档 2025-12-09 20:32:45 +08:00
6b10c99c7a 完整版 2025-12-09 20:21:36 +08:00
54fae88914 待完善 2025-12-09 00:02:26 +08:00
cdfb822f42 把所有代办都换成待办
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-07 23:11:21 +08:00
73aad89f57 避开访问百度的过程
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 22:23:11 +08:00
e1b5f9cfc9 添加对多行的匹配
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 22:16:57 +08:00
35f411fb3a 提醒UI的GIF实现与参数传递
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 16:20:09 +08:00
eed21e6223 new
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 22:38:50 +08:00
bf5c10b7a7 notice test
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 22:23:44 +08:00
274ca0fa9a 初步尝试UI化 2025-12-03 22:00:44 +08:00
c72cdd6a6b 新滤镜,新修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 17:20:46 +08:00
16b0451133 删掉黑白
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 13:32:11 +08:00
cb34813c4b Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot 2025-12-03 13:22:09 +08:00
2de3be271e 最新最热模糊
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 13:10:16 +08:00
f7d2168dac 最新最热 2025-12-03 12:25:39 +08:00
26e10be4ec 修复相关文档 2025-11-28 17:10:18 +08:00
59 changed files with 15308 additions and 160 deletions

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "assets/lexicon/THUOCL"]
path = assets/lexicon/THUOCL
url = https://github.com/thunlp/THUOCL.git
[submodule "assets/oracle"]
path = assets/oracle
url = https://gitea.service.jazzwhom.top/mttu-developers/oracle-source.git

View File

@ -1,3 +1,16 @@
FROM alpine:latest AS artifacts
RUN apk add --no-cache curl xz
WORKDIR /tmp
RUN mkdir -p /artifacts
RUN curl -L -o typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz" \
&& tar -xJf typst.tar.xz \
&& mv typst-x86_64-unknown-linux-musl/typst /artifacts
RUN chmod -R +x /artifacts/
FROM python:3.13-slim AS base
ENV VIRTUAL_ENV=/app/.venv \
@ -38,6 +51,7 @@ RUN uv sync --no-install-project
FROM base AS runtime
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --from=artifacts /artifacts/ /usr/local/bin/
WORKDIR /app

9856
assets/old_font/symtable.csv Normal file

File diff suppressed because it is too large Load Diff

1
assets/oracle Submodule

Submodule assets/oracle added at 9f3c08c5d2

View File

@ -51,6 +51,8 @@ class AlibabaGreen:
@staticmethod
def _detect_sync(content: str) -> bool:
if len(content) == 0:
return True
if not AlibabaGreen.get_config().module_aligreen_enable:
logger.debug("该环境未启用阿里内容审查,直接跳过")
return True

112
konabot/common/artifact.py Normal file
View File

@ -0,0 +1,112 @@
import asyncio
import aiohttp
import hashlib
import platform
from dataclasses import dataclass
from pathlib import Path
import nonebot
from loguru import logger
from nonebot.adapters.discord.config import Config as DiscordConfig
from pydantic import BaseModel
@dataclass
class ArtifactDepends:
url: str
sha256: str
target: Path
required_os: str | None = None
"示例值Windows, Linux, Darwin"
required_arch: str | None = None
"示例值AMD64, x86_64, arm64"
use_proxy: bool = True
"网络问题,赫赫;使用的是 Discord 模块配置的 proxy"
def is_corresponding_platform(self) -> bool:
if self.required_os is not None:
if self.required_os.lower() != platform.system().lower():
return False
if self.required_arch is not None:
if self.required_arch.lower() != platform.machine().lower():
return False
return True
class Config(BaseModel):
prefetch_artifact: bool = False
"是否提前下载好二进制依赖"
artifact_list = []
driver = nonebot.get_driver()
config = nonebot.get_plugin_config(Config)
@driver.on_startup
async def _():
if config.prefetch_artifact:
logger.info("启动检测中:正在检测需求的二进制是否下载")
semaphore = asyncio.Semaphore(10)
async def _task(artifact: ArtifactDepends):
async with semaphore:
await ensure_artifact(artifact)
tasks: set[asyncio.Task] = set()
for a in artifact_list:
tasks.add(asyncio.Task(_task(a)))
await asyncio.gather(*tasks, return_exceptions=False)
logger.info("检测好了")
async def download_artifact(artifact: ArtifactDepends):
proxy = None
if artifact.use_proxy:
discord_config = nonebot.get_plugin_config(DiscordConfig)
proxy = discord_config.discord_proxy
if proxy is not None:
logger.info(f"正在使用 Proxy 下载 TARGET={artifact.target} PROXY={proxy}")
else:
logger.info(f"正在下载 TARGET={artifact.target}")
async with aiohttp.ClientSession(proxy=proxy) as client:
result = await client.get(artifact.url)
if result.status != 200:
logger.warning(f"已经下载了二进制,但是注意服务器没有返回 200 URL={artifact.url} TARGET={artifact.target} CODE={result.status}")
data = await result.read()
artifact.target.write_bytes(data)
if not platform.system().lower() == 'windows':
artifact.target.chmod(0o755)
logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}")
m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower():
logger.warning(f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}")
async def ensure_artifact(artifact: ArtifactDepends):
if not artifact.is_corresponding_platform():
return
if not artifact.target.exists():
logger.info(f"二进制依赖 {artifact.target} 不存在")
if not artifact.target.parent.exists():
artifact.target.parent.mkdir(parents=True, exist_ok=True)
await download_artifact(artifact)
else:
m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower():
logger.info(f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载")
artifact.target.unlink()
await download_artifact(artifact)
def register_artifacts(*artifacts: ArtifactDepends):
artifact_list.extend(artifacts)

View File

@ -1,4 +1,4 @@
from typing import Any
from typing import Any, cast
import openai
from loguru import logger
@ -26,14 +26,14 @@ class LLMInfo(BaseModel):
async def chat(
self,
messages: list[ChatCompletionMessageParam],
messages: list[ChatCompletionMessageParam] | list[dict[str, Any]],
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,
messages=cast(Any, messages),
model=self.model_name,
max_tokens=max_tokens,
timeout=timeout,

View File

@ -207,6 +207,21 @@ async def _ext_img(
await matcher.send(await UniMessage.text(msg).export())
return None
async def _try_ext_img(
evt: Event,
bot: Bot,
matcher: Matcher,
) -> bytes | None:
match await extract_image_data_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
DepImageBytes = Annotated[bytes, nonebot.params.Depends(_ext_img_data)]
DepPILImage = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)]
DepImageBytesOrNone = Annotated[bytes | None, nonebot.params.Depends(_try_ext_img)]

View File

@ -5,8 +5,10 @@ FONTS_PATH = ASSETS_PATH / "fonts"
SRC_PATH = Path(__file__).resolve().parent.parent
DATA_PATH = SRC_PATH.parent / "data"
TMP_PATH = DATA_PATH / "tmp"
LOG_PATH = DATA_PATH / "logs"
CONFIG_PATH = DATA_PATH / "config"
BINARY_PATH = DATA_PATH / "bin"
DOCS_PATH = SRC_PATH / "docs"
DOCS_PATH_MAN1 = DOCS_PATH / "user"
@ -21,4 +23,6 @@ if not LOG_PATH.exists():
LOG_PATH.mkdir()
CONFIG_PATH.mkdir(exist_ok=True)
TMP_PATH.mkdir(exist_ok=True)
BINARY_PATH.mkdir(exist_ok=True)

View File

@ -0,0 +1,34 @@
from typing import Any
from loguru import logger
from nonebot_plugin_alconna import UniMessage
import playwright.async_api
from playwright.async_api import Page
from konabot.common.web_render import WebRenderer, konaweb
async def render_error_message(message: str) -> UniMessage[Any]:
"""
渲染文本消息为错误信息图片。
如果无法访达 Web 端则返回纯文本给用户。
"""
async def page_function(page: Page):
await page.wait_for_function("typeof setContent === 'function'", timeout=3000)
await page.evaluate(
"""(message) => {return setContent(message);}""",
message,
)
try:
img_data = await WebRenderer.render(
url=konaweb("error_report"),
target="#main",
other_function=page_function,
)
return UniMessage.image(raw=img_data)
except (playwright.async_api.Error, ConnectionError) as e:
logger.warning("渲染报错信息图片时出错了,回退到文本 ERR={}", e)
return UniMessage.text(message)

View File

@ -0,0 +1,4 @@
# 宾几人
查询 Bingo 有几个人。直接发送给 Bot 即可。

View File

@ -19,14 +19,26 @@ fx [滤镜名称] <参数1> <参数2> ...
## 可用滤镜列表
### 基础滤镜
* ```fx 模糊 <半径=10>```
* ```fx 马赛克 <像素大小=10>```
* ```fx 轮廓```
* ```fx 锐化```
* ```fx 边缘增强```
* ```fx 浮雕```
* ```fx 查找边缘```
* ```fx 平滑```
* ```fx 暗角 <半径=1.5>```
* ```fx 发光 <强度=0.5> <模糊半径=15>```
* ```fx 噪点 <数量=0.05>```
* ```fx 素描```
* ```fx 阴影 <偏移量X=10> <偏移量Y=10> <模糊量=10> <不透明度=0.5> <阴影颜色=black>```
### 模糊滤镜
* ```fx 模糊 <半径=10>```
* ```fx 马赛克 <像素大小=10>```
* ```fx 径向模糊 <强度=3.0> <采样量=6>```
* ```fx 旋转模糊 <强度=30.0> <采样量=6>```
* ```fx 方向模糊 <角度=0.0> <距离=20> <采样量=6>```
* ```fx 缩放模糊 <强度=0.1> <采样量=6>```
* ```fx 边缘模糊 <半径=10.0>```
### 色彩处理滤镜
* ```fx 反色```
@ -36,15 +48,59 @@ fx [滤镜名称] <参数1> <参数2> ...
* ```fx 亮度 <因子=1.5>```
* ```fx 色彩 <因子=1.5>```
* ```fx 色调 <颜色="rgb(255,0,0)">```
* ```fx RGB分离 <偏移量=5>```
* ```fx 叠加颜色 <颜色列表=[rgb(255,0,0)|(0,0)+rgb(0,255,0)|(0,100)+rgb(0,0,255)|(50,100)]> <叠加模式=overlay>```
* ```fx 像素抖动 <最大偏移量=2>```
* ```fx 半调 <半径=5>```
* ```fx 描边 <半径=5> <颜色=black>```
* ```fx 形状描边 <半径=5> <颜色=black> <粗糙度=None>```
### 几何变换滤镜
* ```fx 缩放 <比例=1.5>```
* ```fx 平移 <x偏移量=10> <y偏移量=10>```
* ```fx 缩放 <比例(X)=1.5> <比例Y=None>```
* ```fx 旋转 <角度=45>```
* ```fx 透视变换 <变换矩阵>```
* ```fx 裁剪 <左=0> <上=0> <右=100> <下=100>(百分比)```
* ```fx 拓展边缘 <拓展量=10>```
* ```fx 波纹 <振幅=5> <波长=20>```
* ```fx 光学补偿 <数量=100> <反转=false>```
* ```fx 球面化 <强度=0.5>```
* ```fx 镜像 <角度=90>```
* ```fx 水平翻转```
* ```fx 垂直翻转```
* ```fx 复制 <目标位置=(100,100)> <缩放=1.0> <源区域=(0,0,100,100)>(百分比)```
### 特殊效果滤镜
* ```fx 设置通道 <通道=A>```
* 可用 R、G、B、A。
* ```fx 设置遮罩```
* ```fx 色键 <目标颜色="rgb(255,0,0)"> <容差=60>```
* ```fx 晃动 <最大偏移量=5> <运动模糊=False>```
* ```fx 动图 <帧率=10>```
### 多图像处理器
* ```fx 存入图像 <目标名称>```
* 目标名称是图像的代名词,图像最长可存 12 小时,如果公用容量满了图像也会被删除。
* 该项仅可于首项使用。
* ```fx 读取图像 <目标名称>```
* 该项仅可于首项使用。
* ```fx 暂存图像```
* 此项默认插入存储在暂存列表中第一张图像的后面。
* ```fx 交换图像 <交换项=2> <交换项=1>```
* ```fx 删除图像 <删除索引=1>```
* ```fx 选择图像 <目标索引=2>```
### 多图像混合
* ```fx 混合图像 <模式=normal> <alpha=0.5>```
* ```fx 覆盖图像```
### 生成类
* ```fx 覆加颜色 <颜色列表=[rgb(255,0,0)|(0,0)+rgb(0,255,0)|(0,100)+rgb(0,0,255)|(50,100)]>```
* ```fx 生成图层 <宽度=512> <高度=512>```
* ```fx 生成文本 <文本内容=请输入文本> <字体大小=32> <文字颜色=black> <字体文件=HarmonyOS_Sans_SC_Regular.ttf>```
## 颜色名称支持
- **格式**:颜色列表采用 ```[颜色|位置+颜色|位置+颜色|位置]``` 的格式,位置是形如```(x百分比,y百分比)```的元组。
- **基本颜色**:红、绿、蓝、黄、紫、黑、白、橙、粉、灰、青、靛、棕
- **修饰词**:浅、深、亮、暗(可组合使用,如`浅红`、`深蓝`
- **RGB格式**`rgb(255,0,0)`、`rgb(0,255,0)`、`(255,0,0)` 等

View File

@ -0,0 +1,10 @@
# 指令介绍
根据文字生成 k8x12S
> 「现在还不知道k8x12S是什么的可以开除界隈籍了」—— Louis, 2025/12/31
## 使用指南
`k8x12S 安心をしてください`

View File

@ -1,10 +1,10 @@
## 指令介绍
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的办事项。
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的办事项。
## 指令示例
- **`ntfy 创建`**
创建一个随机的 ntfy 订阅主题来提醒办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP或者使用网页版 ntfy。
创建一个随机的 ntfy 订阅主题来提醒办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP或者使用网页版 ntfy。
- **`ntfy 创建 kagami-notice`**
创建一个名称包含 `kagami-notice` 的 ntfy 订阅主题。

View File

@ -0,0 +1,174 @@
# 文字处理机器人使用手册(小白友好版)
欢迎使用文字处理机器人!你不需要懂编程,只要会打字,就能用它完成各种文字操作——比如加密、解密、打乱顺序、进制转换、排版整理等。
---
## 一、基础演示
在 QQ 群里这样使用:
1. **直接输入命令**(适合短文本):
```
/textfx reverse 你好世界
```
→ 机器人回复:`界世好你`
2. **先发一段文字,再用命令处理它**(适合长文本):
- 先发送:`Hello, World!`
- 回复这条消息,输入:
```
/textfx b64 encode
```
→ 机器人返回:`SGVsbG8sIFdvcmxkIQ==`
> 命令可写为 `/textfx`、`/处理文字` 或 `/处理文本`。
> 若不回复消息,命令会处理当前行后面的文本。
---
## 二、流水线语法(超简单)
- 用 `|` 连接多个操作,前一个的输出自动作为后一个的输入。
- 用 `;` 分隔多条独立指令,它们各自产生输出,最终合并显示。
- 用 `>` 或 `>>` 把结果保存起来(见下文),被重定向的指令不会产生输出。
**例子**:把"HELLO"先反转,再转成摩斯电码:(转换为摩斯电码功能暂未实现)
```
textfx reverse HELLO | morse en
```
→ 输出:`--- .-.. .-.. . ....`
**例子**:多条指令各自输出:
```
textfx echo 你好; echo 世界
```
→ 输出:
```
你好
世界
```
**例子**:重定向的指令不输出,其余正常输出:
```
textfx echo 1; echo 2 > a; echo 3
```
→ 输出:
```
1
3
```
---
## 三、功能清单(含示例)
### reverse或 rev、反转
反转文字。
示例:`/textfx reverse 爱你一万年` → `年万一你爱`
### b64或 base64
Base64 编码或解码。
示例:`/textfx b64 encode 你好` → `5L2g5aW9`
示例:`/textfx b64 decode 5L2g5aW9` → `你好`
### caesar或 凯撒、rot
凯撒密码(仅对英文字母有效)。
示例:`/textfx caesar 3 ABC` → `DEF`
示例:`/textfx caesar -3 DEF` → `ABC`
### morse或 摩斯)
将摩斯电码解码为文字(支持英文和日文)。字符间用空格,单词间用 `/`。
示例:`/textfx morse en .... . .-.. .-.. ---` → `HELLO`
示例:`/textfx morse jp -... --.-- -.. --.. ..- ..` → `ハアホフウイ`
### baseconv或 进制转换)
在不同进制之间转换数字。
示例:`/textfx baseconv 2 10 1101` → `13`
示例:`/textfx baseconv 10 16 255` → `FF`
### shuffle或 打乱)
随机打乱文字顺序。
示例:`/textfx shuffle abcdef` → `fcbade`(每次结果不同)
### sort或 排序)
将文字按字符顺序排列。
示例:`/textfx sort dbca` → `abcd`
### b64hex
在 Base64 和十六进制之间互转。
示例:`/textfx b64hex dec SGVsbG8=` → `48656c6c6f`
示例:`/textfx b64hex enc 48656c6c6f` → `SGVsbG8=`
### align或 format、排版
按指定格式分组排版文字。
示例:`/textfx align 2 4 0123456789abcdef` →
```
01 23 45 67
89 ab cd ef
```
### echo
输出指定文字。
示例:`/textfx echo 你好` → `你好`
### cat
读取并拼接缓存内容,类似 Unix cat 命令。
- 无参数时直接传递标准输入(管道输入或回复的消息)。
- 使用 `-` 代表标准输入,可与缓存名混合使用。
- 支持多个参数,按顺序拼接输出。
示例:
- 传递输入:`/textfx echo 你好 | cat` → `你好`
- 读取缓存:`/textfx cat mytext` → 输出 mytext 的内容
- 拼接多个缓存:`/textfx cat a b c` → 依次拼接缓存 a、b、c
- 混合标准输入和缓存:`/textfx echo 前缀 | cat - mytext` → 拼接标准输入与缓存 mytext
### 缓存操作(保存中间结果)
- 保存:`/textfx reverse 你好 > mytext`(不输出,存入 mytext
- 读取:`/textfx cat mytext` → `好你`
- 追加:`/textfx echo world >> mytext`
- 删除:`/textfx rm mytext`
> 缓存仅在当前对话中有效,重启后清空。
### replace或 替换、sed
替换文字(支持正则表达式)。
示例(普通):`/textfx replace 世界 宇宙 你好世界` → `你好宇宙`
示例(正则):`/textfx replace \d+ [数字] 我有123个苹果` → `我有[数字]个苹果`
### trim或 strip、去空格
去除文本首尾空白字符。
示例:`/textfx trim " 你好 "` → `你好`
示例:`/textfx echo " hello " | trim` → `hello`
### ltrim或 lstrip
去除文本左侧空白字符。
示例:`/textfx ltrim " 你好 "` → `你好 `
### rtrim或 rstrip
去除文本右侧空白字符。
示例:`/textfx rtrim " 你好 "` → ` 你好`
### squeeze或 压缩空白)
将连续的空白字符(空格、制表符)压缩为单个空格。
示例:`/textfx squeeze "你好 世界"` → `你好 世界`
### lines或 行处理)
按行处理文本,支持以下子命令:
- `lines trim` — 去除每行首尾空白
- `lines empty` — 去除所有空行
- `lines squeeze` — 将连续空行压缩为一行
示例:`/textfx echo " hello\n\n\n world " | lines trim` → `hello\n\n\n world`
示例:`/textfx echo "a\n\n\nb" | lines squeeze` → `a\n\nb`
---
## 常见问题
- **没反应?** 可能内容被安全系统拦截,机器人会提示“内容被拦截”。
- **只支持纯文字**,暂不支持图片或文件。
- 命令拼错时,机器人会提示“不存在名为 xxx 的函数”,请检查名称。
快去试试吧!用法核心:**`/textfx` + 你的操作**

View File

@ -9,7 +9,7 @@ John: 11-28 16:50:37
谁来总结一下今天的工作?
Jack: 11-28 16:50:55
[引用John的消息] tqszm
[引用John的消息] @此方Bot tqszm
此方Bot: 11-28 16:50:56
slzjyxjtdgz
@ -18,7 +18,7 @@ slzjyxjtdgz
或者,你也可以直接以正常指令的方式调用:
```
提取首字母 中山大学
@此方Bot 提取首字母 中山大学
> zsdx
```

View File

@ -0,0 +1,4 @@
# Typst 渲染
只需使用 `typst ...` 就可以渲染 Typst 了

View File

@ -1,7 +0,0 @@
## 指令介绍
**黑白** - 将图片经过一个黑白滤镜的处理
## 示例
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可

View File

@ -0,0 +1,52 @@
from io import BytesIO
import base64
import re
from loguru import logger
from nonebot import on_message
from nonebot.rule import Rule
from konabot.common.apis.ali_content_safety import AlibabaGreen
from konabot.common.llm import get_llm
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.nb.extract_image import DepPILImage
from konabot.common.nb.match_keyword import match_keyword
cmd = on_message(rule=Rule(match_keyword(re.compile(r"^千问识图\s*$"))))
@cmd.handle()
async def _(img: DepPILImage, target: DepLongTaskTarget):
if 1:
return #TODO:这里还没写完,还有 Bug 要修
jpeg_data = BytesIO()
if img.width > 2160:
img = img.resize((2160, img.height * 2160 // img.width))
if img.height > 2160:
img = img.resize((img.width * 2160 // img.height, 2160))
img = img.convert("RGB")
img.save(jpeg_data, format="jpeg", optimize=True, quality=85)
data_url = "data:image/jpeg;base64,"
data_url += base64.b64encode(jpeg_data.getvalue()).decode('ascii')
llm = get_llm("qwen3-vl-plus")
res = await llm.chat([
{ "role": "user", "content": [
{ "type": "image_url", "image_url": {
"url": data_url
} },
{ "type": "text", "text": "请你提取这张图片中的所有文字,并尽量按照原图的排版输出,不需要其他内容" },
] }
])
result = res.content
logger.info(res)
if result is None:
await target.send_message("提取失败:可能存在网络异常")
return
if not await AlibabaGreen.detect(result):
await target.send_message("提取失败:图片中可能存在一些不合适的内容")
return
await target.send_message(result, at=False)

View File

@ -1,18 +1,29 @@
import re
from nonebot import on_message
from nonebot import get_plugin_config, on_message
from nonebot_plugin_alconna import Reference, Reply, UniMsg
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11.event import GroupMessageEvent as OB11GroupEvent
from pydantic import BaseModel
class Config(BaseModel):
bilifetch_enabled_groups: list[int] = []
config = get_plugin_config(Config)
pattern = (
r"^(?:(?:av|cv)\d+|BV[a-zA-Z0-9]{10})|"
r"(?:b23\.tv|bili(?:22|23|33|2233)\.cn|\.bilibili\.com|QQ小程序(?:&amp;#93;|&#93;|\])哔哩哔哩).{0,500}"
)
def _rule(msg: UniMsg):
def _rule(msg: UniMsg, evt: Event) -> bool:
if isinstance(evt, OB11GroupEvent):
if evt.group_id not in config.bilifetch_enabled_groups:
return False
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):

View File

@ -0,0 +1,132 @@
from pathlib import Path
import subprocess
import tempfile
from loguru import logger
from nonebot import on_command
from pydantic import BaseModel
from nonebot.adapters import Event, Bot
from nonebot_plugin_alconna import UniMessage, UniMsg
from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent
from konabot.common.artifact import ArtifactDepends, ensure_artifact, register_artifacts
from konabot.common.data_man import DataManager
from konabot.common.path import BINARY_PATH, DATA_PATH
arti_ccleste_wrap_linux = ArtifactDepends(
url="https://github.com/Passthem-desu/pt-ccleste-wrap/releases/download/v0.1.5/ccleste-wrap",
sha256="ba4118c6465d1ca1547cdd1bd11c6b9e6a6a98ea8967b55485aeb6b77bb7e921",
target=BINARY_PATH / "ccleste-wrap",
required_os="Linux",
required_arch="x86_64",
)
arti_ccleste_wrap_windows = ArtifactDepends(
url="https://github.com/Passthem-desu/pt-ccleste-wrap/releases/download/v0.1.5/ccleste-wrap.exe",
sha256="7df382486a452485cdcf2115eabd7f772339ece470ab344074dc163fc7981feb",
target=BINARY_PATH / "ccleste-wrap.exe",
required_os="Windows",
required_arch="AMD64",
)
register_artifacts(arti_ccleste_wrap_linux)
register_artifacts(arti_ccleste_wrap_windows)
class CelesteStatus(BaseModel):
records: dict[str, str] = {}
celeste_status = DataManager(CelesteStatus, DATA_PATH / "celeste-status.json")
cmd = on_command(cmd="celeste", aliases={"蔚蓝", "爬山", "鳌太线"})
@cmd.handle()
async def _(msg: UniMsg, evt: Event, bot: Bot):
prev = None
if isinstance(evt, OB11MessageEvent):
if evt.reply is not None:
prev = f"QQ:{bot.self_id}:" + str(evt.reply.message_id)
else:
for seg in evt.get_message():
if seg.type == 'reply':
msgid = seg.get('id')
prev = f"QQ:{bot.self_id}:" + str(msgid)
actions = msg.extract_plain_text().strip().removeprefix("celeste")
for alias in {"蔚蓝", "爬山", "鳌太线"}:
actions = actions.removeprefix(alias)
actions = actions.strip()
if len(actions) == 0:
return
if prev is not None:
async with celeste_status.get_data() as data:
prev = data.records.get(prev)
await ensure_artifact(arti_ccleste_wrap_linux)
await ensure_artifact(arti_ccleste_wrap_windows)
bin: Path | None = None
for arti in (
arti_ccleste_wrap_linux,
arti_ccleste_wrap_windows,
):
if not arti.is_corresponding_platform():
continue
bin = arti.target
if not bin.exists():
continue
break
if bin is None:
logger.warning("Celeste 模块没有找到该系统需要的二进制文件")
return
if prev is not None:
prev_append = ["-p", prev]
else:
prev_append = []
try:
with tempfile.TemporaryDirectory() as _tempdir:
tempdir = Path(_tempdir)
gif_path = tempdir / "render.gif"
cmd_celeste = [
bin,
"-a",
actions,
"-o",
gif_path,
] + prev_append
logger.info(f"执行指令调用 celeste: CMD={cmd_celeste}")
res = subprocess.run(cmd_celeste, timeout=5, capture_output=True)
if res.returncode != 0:
logger.warning(f"渲染 Celeste 时的输出不是 0 CODE={res.returncode} STDOUT={res.stdout} STDERR={res.stderr}")
await UniMessage.text(f"渲染 Celeste 时出错啦!下面是输出:\n\n{res.stdout.decode()}{res.stderr.decode()}").send(evt, bot, at_sender=True)
return
if not gif_path.exists():
logger.warning("没有找到 Celeste 渲染的文件")
await UniMessage.text("渲染 Celeste 时出错啦!").send(evt, bot, at_sender=True)
return
gif_data = gif_path.read_bytes()
except TimeoutError:
logger.warning("在渲染 Celeste 时超时了")
await UniMessage("渲染 Celeste 时超时了!请检查你的操作清单,不能太长").send(evt, bot, at_sender=True)
return
receipt = await UniMessage.image(raw=gif_data).send(evt, bot)
async with celeste_status.get_data() as data:
if prev:
actions = prev + "\n" + actions
if isinstance(evt, OB11MessageEvent):
for _msgid in receipt.msg_ids:
msgid = _msgid["message_id"]
data.records[f"QQ:{bot.self_id}:{msgid}"] = actions
else:
for msgid in receipt.msg_ids:
data.records[f"DISCORD:{bot.self_id}:{msgid}"] = actions

View File

@ -1,58 +1,277 @@
import asyncio as asynkio
from io import BytesIO
from inspect import signature
import random
from konabot.common.nb.extract_image import DepPILImage
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.nb.extract_image import DepImageBytesOrNone
from nonebot.adapters import Event as BaseEvent
from nonebot import on_message
from nonebot import on_message, logger
from nonebot_plugin_alconna import (
UniMessage,
UniMsg
)
from konabot.plugins.fx_process.fx_handle import ImageFilterStorage
from konabot.plugins.fx_process.fx_manager import ImageFilterManager
from PIL import Image, ImageSequence
from konabot.plugins.fx_process.types import FilterItem, ImageRequireSignal, ImagesListRequireSignal, SenderInfo, StoredInfo
def try_convert_type(param_type, input_param, sender_info: SenderInfo = None) -> tuple[bool, any]:
converted_value = None
try:
if param_type is float:
converted_value = float(input_param)
elif param_type is int:
converted_value = int(input_param)
elif param_type is bool:
converted_value = input_param.lower() in ['true', '1', 'yes', '', '']
elif param_type is Image.Image:
converted_value = ImageRequireSignal()
return False, converted_value
elif param_type is SenderInfo:
converted_value = sender_info
return False, converted_value
elif param_type == list[Image.Image]:
converted_value = ImagesListRequireSignal()
return False, converted_value
elif param_type is str:
if input_param is None:
return False, None
converted_value = str(input_param)
else:
return False, None
except Exception:
return False, None
return True, converted_value
def prase_input_args(input_str: str, sender_info: SenderInfo = None) -> list[FilterItem]:
# 按分号或换行符分割参数
args = []
for part in input_str.replace('\n', ';').split(';'):
part = part.strip()
if not part:
continue
split_part = part.split()
filter_name = split_part[0]
if not ImageFilterManager.has_filter(filter_name):
continue
filter_func = ImageFilterManager.get_filter(filter_name)
input_filter_args = split_part[1:]
# 获取函数最大参数数量
sig = signature(filter_func)
max_params = len(sig.parameters) - 1 # 减去第一个参数 image
# 从 args 提取参数,并转换为适当类型
func_args = []
for i in range(0, max_params):
# 尝试将参数转换为函数签名中对应的类型
param = list(sig.parameters.values())[i + 1]
param_type = param.annotation
# 根据函数所需要的参数,从输入参数中提取,如果不匹配就使用默认值,将当前参数递交给下一个循环
input_param = input_filter_args[0] if len(input_filter_args) > 0 else None
state, converted_param = try_convert_type(param_type, input_param, sender_info)
if state:
input_filter_args.pop(0)
if converted_param is None and param.default != param.empty:
converted_param = param.default
func_args.append(converted_param)
args.append(FilterItem(name=filter_name,filter=filter_func, args=func_args))
return args
def handle_filters_to_image(images: list[Image.Image], filters: list[FilterItem]) -> Image.Image:
for filter_item in filters:
logger.debug(f"{filter_item}")
filter_func = filter_item.filter
func_args = filter_item.args
# 检测参数中是否有 ImageRequireSignal如果有则传入对应数量的图像列表
if any(isinstance(arg, ImageRequireSignal) for arg in func_args):
# 替换 ImageRequireSignal 为 images 对应索引的图像
actual_args = []
img_signal_count = 1 # 从 images[1] 开始取图像
for arg in func_args:
if isinstance(arg, ImageRequireSignal):
if img_signal_count >= len(images):
raise BotExceptionMessage("图像数量不足,无法满足滤镜需求!")
actual_args.append(images[img_signal_count])
img_signal_count += 1
else:
actual_args.append(arg)
func_args = actual_args
# 检测参数中是否有 ImagesListRequireSignal如果有则传入整个图像列表
if any(isinstance(arg, ImagesListRequireSignal) for arg in func_args):
actual_args = []
for arg in func_args:
if isinstance(arg, ImagesListRequireSignal):
actual_args.append(images)
else:
actual_args.append(arg)
func_args = actual_args
logger.debug(f"Applying filter: {filter_item.name} with args: {func_args}")
images[0] = filter_func(images[0], *func_args)
return images[0]
def copy_images_by_index(images: list[Image.Image], index: int) -> list[Image.Image]:
# 将导入图像列表复制为新的图像列表,如果是动图,那么就找到对应索引下的帧
new_images = []
for img in images:
if getattr(img, "is_animated", False):
frames = img.n_frames
frame_idx = index % frames
img.seek(frame_idx)
new_images.append(img.copy())
else:
new_images.append(img.copy())
return new_images
def generate_image(images: list[Image.Image], filters: list[FilterItem]) -> Image.Image:
# 处理位于最前面的生成类滤镜
while filters and filters[0].name.strip() in ImageFilterManager.generate_filter_map:
gen_filter = filters.pop(0)
gen_func = gen_filter.filter
func_args = gen_filter.args[1:] # 去掉第一个 list 参数
gen_func(None, images, *func_args)
def save_or_load_image(images: list[Image.Image], filters: list[FilterItem], sender_info: SenderInfo) -> StoredInfo | None:
stored_info = None
# 处理位于最前面的“读取图像”、“存入图像”
if not filters:
return
while filters and filters[0].name.strip() in ["读取图像", "存入图像"]:
if filters[0].name.strip() == "读取图像":
load_filter = filters.pop(0)
path = load_filter.args[0] if load_filter.args else ""
ImageFilterStorage.load_image(None, path, images, sender_info)
elif filters[0].name.strip() == "存入图像":
store_filter = filters.pop(0)
name = store_filter.args[0] if store_filter.args[0] else str(random.randint(10000,99999))
stored_info = ImageFilterStorage.store_image(images[0], name, sender_info)
# 将剩下的“读取图像”或“存入图像”参数全部删除,避免后续非法操作
filters[:] = [f for f in filters if f.name.strip() not in ["读取图像", "存入图像"]]
return stored_info
async def apply_filters_to_images(images: list[Image.Image], filters: list[FilterItem], sender_info: SenderInfo) -> BytesIO | StoredInfo:
# 先处理存取图像、生成图像的操作
stored_info = save_or_load_image(images, filters, sender_info)
generate_image(images, filters)
if stored_info and len(filters) <= 0:
return stored_info
if len(images) <= 0:
raise BotExceptionMessage("没有可处理的图像!")
# 检测是否需要将静态图视作动图处理
frozen_to_move = any(
filter_item.name == "动图"
for filter_item in filters
)
static_fps = 10
# 找到动图参数 fps
if frozen_to_move:
for filter_item in filters:
if filter_item.name == "动图" and filter_item.args:
try:
static_fps = int(filter_item.args[0])
except Exception:
static_fps = 10
break
# 如果 image 是动图,则逐帧处理
img = images[0]
logger.debug("开始图像处理")
output = BytesIO()
if getattr(img, "is_animated", False) or frozen_to_move:
frames = []
append_images = []
if getattr(img, "is_animated", False):
logger.debug("处理动图帧")
else:
# 将静态图视作单帧动图处理,拷贝 10 帧
logger.debug("处理静态图为多帧动图")
append_images = [img.copy() for _ in range(10)]
img.info['duration'] = int(1000 / static_fps)
async def process_single_frame(frame_images: list[Image.Image], frame_idx: int) -> Image.Image:
"""处理单帧的异步函数"""
logger.debug(f"开始处理帧 {frame_idx}")
result = await asynkio.to_thread(handle_filters_to_image, frame_images, filters)
logger.debug(f"完成处理帧 {frame_idx}")
return result
# 并发处理所有帧
tasks = []
all_frames = []
for i, frame in enumerate(list(ImageSequence.Iterator(img)) + append_images):
all_frames.append(frame.copy())
images_copy = copy_images_by_index(images, i)
task = process_single_frame(images_copy, i)
tasks.append(task)
frames = await asynkio.gather(*tasks, return_exceptions=False)
# 检查是否有处理失败的帧
for i, result in enumerate(frames):
if isinstance(result, Exception):
logger.error(f"{i} 处理失败: {result}")
# 使用原始帧作为回退
frames[i] = all_frames[i]
logger.debug("保存动图")
frames[0].save(
output,
format="GIF",
save_all=True,
append_images=frames[1:],
loop=img.info.get("loop", 0),
disposal=img.info.get("disposal", 2),
duration=img.info.get("duration", 100),
)
logger.debug("Animated image saved")
else:
img = handle_filters_to_image(images=images, filters=filters)
img.save(output, format="PNG")
logger.debug("Image processing completed")
output.seek(0)
return output
def is_fx_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
txt = msg.extract_plain_text()
if "fx" not in txt[:3]:
if "fx" not in txt[:3].lower():
return False
return True
fx_on = on_message(rule=is_fx_mentioned)
@fx_on.handle()
async def _(msg: UniMsg, event: BaseEvent, img: DepPILImage):
async def _(msg: UniMsg, event: BaseEvent, target: DepLongTaskTarget, image_data: DepImageBytesOrNone):
preload_imgs = []
# 提取图像
try:
preload_imgs.append(Image.open(BytesIO(image_data)))
except Exception:
logger.info("No image found in message for FX processing.")
args = msg.extract_plain_text().split()
if len(args) < 2:
return
filter_name = args[1]
filter_func = ImageFilterManager.get_filter(filter_name)
if not filter_func:
return
# 获取函数最大参数数量
sig = signature(filter_func)
max_params = len(sig.parameters) - 1 # 减去第一个参数 image
# 从 args 提取参数,并转换为适当类型
func_args = []
for i in range(2, min(len(args), max_params + 2)):
# 尝试将参数转换为函数签名中对应的类型
param = list(sig.parameters.values())[i - 1]
param_type = param.annotation
arg_value = args[i]
try:
if param_type is float:
converted_value = float(arg_value)
elif param_type is int:
converted_value = int(arg_value)
else:
converted_value = arg_value
except Exception:
converted_value = arg_value
func_args.append(converted_value)
# 应用滤镜
out_img = filter_func(img, *func_args)
output = BytesIO()
out_img.save(output, format="PNG")
await fx_on.send(await UniMessage().image(raw=output).export())
sender_info = SenderInfo(
group_id=target.channel_id,
qq_id=target.target_id
)
filters = prase_input_args(msg.extract_plain_text()[2:], sender_info=sender_info)
# if not filters:
# return
output = await apply_filters_to_images(preload_imgs, filters, sender_info)
if isinstance(output,StoredInfo):
await fx_on.send(await UniMessage().text(f"图像已存为「{output.name}」!").export())
elif isinstance(output,BytesIO):
await fx_on.send(await UniMessage().image(raw=output).export())

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
from typing import Optional
from konabot.plugins.fx_process.fx_handle import ImageFilterImplement
from konabot.plugins.fx_process.fx_handle import ImageFilterEmpty, ImageFilterImplement, ImageFilterStorage
class ImageFilterManager:
filter_map = {
@ -21,8 +21,67 @@ class ImageFilterManager:
"缩放": ImageFilterImplement.apply_resize,
"波纹": ImageFilterImplement.apply_wave,
"色键": ImageFilterImplement.apply_color_key,
"暗角": ImageFilterImplement.apply_vignette,
"发光": ImageFilterImplement.apply_glow,
"RGB分离": ImageFilterImplement.apply_rgb_split,
"光学补偿": ImageFilterImplement.apply_optical_compensation,
"球面化": ImageFilterImplement.apply_spherize,
"旋转": ImageFilterImplement.apply_rotate,
"透视变换": ImageFilterImplement.apply_perspective_transform,
"裁剪": ImageFilterImplement.apply_crop,
"噪点": ImageFilterImplement.apply_noise,
"平移": ImageFilterImplement.apply_translate,
"拓展边缘": ImageFilterImplement.apply_expand_edges,
"素描": ImageFilterImplement.apply_sketch,
"叠加颜色": ImageFilterImplement.apply_gradient_overlay,
"阴影": ImageFilterImplement.apply_shadow,
"径向模糊": ImageFilterImplement.apply_radial_blur,
"旋转模糊": ImageFilterImplement.apply_spin_blur,
"方向模糊": ImageFilterImplement.apply_directional_blur,
"边缘模糊": ImageFilterImplement.apply_focus_blur,
"缩放模糊": ImageFilterImplement.apply_zoom_blur,
"镜像": ImageFilterImplement.apply_mirror_half,
"水平翻转": ImageFilterImplement.apply_flip_horizontal,
"垂直翻转": ImageFilterImplement.apply_flip_vertical,
"复制": ImageFilterImplement.copy_area,
"晃动": ImageFilterImplement.apply_random_wiggle,
"动图": ImageFilterEmpty.empty_filter_param,
"像素抖动": ImageFilterImplement.apply_pixel_jitter,
"描边": ImageFilterImplement.apply_stroke,
"形状描边": ImageFilterImplement.apply_shape_stroke,
"半调": ImageFilterImplement.apply_halftone,
"设置通道": ImageFilterImplement.apply_set_channel,
"设置遮罩": ImageFilterImplement.apply_set_mask,
# 图像处理
"存入图像": ImageFilterStorage.store_image,
"读取图像": ImageFilterStorage.load_image,
"暂存图像": ImageFilterStorage.temp_store_image,
"交换图像": ImageFilterStorage.swap_image_index,
"删除图像": ImageFilterStorage.delete_image_by_index,
"选择图像": ImageFilterStorage.select_image_by_index,
# 多图像处理
"混合图像": ImageFilterImplement.apply_blend,
"覆盖图像": ImageFilterImplement.apply_overlay,
# 生成式
"覆加颜色": ImageFilterImplement.generate_solid,
}
generate_filter_map = {
"生成图层": ImageFilterImplement.generate_empty,
"生成文本": ImageFilterImplement.generate_text
}
@classmethod
def get_filter(cls, name: str) -> Optional[callable]:
return cls.filter_map.get(name)
if name in cls.filter_map:
return cls.filter_map[name]
elif name in cls.generate_filter_map:
return cls.generate_filter_map[name]
else:
return None
@classmethod
def has_filter(cls, name: str) -> bool:
return name in cls.filter_map or name in cls.generate_filter_map

View File

@ -0,0 +1,344 @@
import re
from konabot.plugins.fx_process.color_handle import ColorHandle
import numpy as np
from PIL import Image, ImageDraw
from typing import List, Tuple, Dict, Optional
class GradientGenerator:
"""渐变生成器类"""
def __init__(self):
self.has_numpy = hasattr(np, '__version__')
def parse_color_list(self, color_list_str: str) -> List[Dict]:
"""解析渐变颜色列表字符串
Args:
color_list_str: 格式如 '[rgb(255,0,0)|(0,0)+rgb(0,255,0)|(0,100)+rgb(0,0,255)|(50,100)]'
Returns:
list: 包含颜色和位置信息的字典列表
"""
color_nodes = []
color_list_str = color_list_str.strip('[]').strip()
matches = color_list_str.split('+')
for single_str in matches:
color_str = single_str.split('|')[0]
pos_str = single_str.split('|')[1] if '|' in single_str else '0,0'
color = ColorHandle.parse_color(color_str.strip())
try:
pos_str = pos_str.replace('(', '').replace(')', '')
x_str, y_str = pos_str.split(',')
x_percent = float(x_str.strip().replace('%', ''))
y_percent = float(y_str.strip().replace('%', ''))
x_percent = max(0, min(100, x_percent))
y_percent = max(0, min(100, y_percent))
except:
x_percent = 0
y_percent = 0
color_nodes.append({
'color': color,
'position': (x_percent / 100.0, y_percent / 100.0)
})
if not color_nodes:
color_nodes = [
{'color': (255, 0, 0), 'position': (0, 0)},
{'color': (0, 0, 255), 'position': (1, 1)}
]
return color_nodes
def create_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
"""创建渐变图像
Args:
width: 图像宽度
height: 图像高度
color_nodes: 颜色节点列表
Returns:
Image.Image: 渐变图像
"""
if len(color_nodes) == 1:
return Image.new('RGB', (width, height), color_nodes[0]['color'])
elif len(color_nodes) == 2:
return self._create_linear_gradient(width, height, color_nodes)
else:
return self._create_radial_gradient(width, height, color_nodes)
def _create_linear_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
"""创建线性渐变"""
color1 = color_nodes[0]['color']
color2 = color_nodes[1]['color']
pos1 = color_nodes[0]['position']
pos2 = color_nodes[1]['position']
if self.has_numpy:
return self._create_linear_gradient_numpy(width, height, color1, color2, pos1, pos2)
else:
return self._create_linear_gradient_pil(width, height, color1, color2, pos1, pos2)
def _create_linear_gradient_numpy(self, width: int, height: int,
color1: Tuple, color2: Tuple,
pos1: Tuple, pos2: Tuple) -> Image.Image:
"""使用numpy创建线性渐变"""
# 创建坐标网格
x = np.linspace(0, 1, width)
y = np.linspace(0, 1, height)
xx, yy = np.meshgrid(x, y)
# 计算渐变方向
dx = pos2[0] - pos1[0]
dy = pos2[1] - pos1[1]
length_sq = dx * dx + dy * dy
if length_sq > 0:
# 计算投影参数
t = ((xx - pos1[0]) * dx + (yy - pos1[1]) * dy) / length_sq
t = np.clip(t, 0, 1)
else:
t = np.zeros_like(xx)
# 插值颜色
r = color1[0] + (color2[0] - color1[0]) * t
g = color1[1] + (color2[1] - color1[1]) * t
b = color1[2] + (color2[2] - color1[2]) * t
# 创建图像
gradient_array = np.stack([r, g, b], axis=-1).astype(np.uint8)
return Image.fromarray(gradient_array)
def _create_linear_gradient_pil(self, width: int, height: int,
color1: Tuple, color2: Tuple,
pos1: Tuple, pos2: Tuple) -> Image.Image:
"""使用PIL创建线性渐变没有numpy时使用"""
gradient = Image.new('RGB', (width, height))
draw = ImageDraw.Draw(gradient)
# 判断渐变方向
if abs(pos1[0] - pos2[0]) < 0.01: # 垂直渐变
y1 = int(pos1[1] * (height - 1))
y2 = int(pos2[1] * (height - 1))
if y2 < y1:
y1, y2 = y2, y1
color1, color2 = color2, color1
if y2 > y1:
for y in range(height):
if y <= y1:
fill_color = color1
elif y >= y2:
fill_color = color2
else:
ratio = (y - y1) / (y2 - y1)
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
fill_color = (r, g, b)
draw.line([(0, y), (width, y)], fill=fill_color)
else:
draw.rectangle([0, 0, width, height], fill=color1)
elif abs(pos1[1] - pos2[1]) < 0.01: # 水平渐变
x1 = int(pos1[0] * (width - 1))
x2 = int(pos2[0] * (width - 1))
if x2 < x1:
x1, x2 = x2, x1
color1, color2 = color2, color1
if x2 > x1:
for x in range(width):
if x <= x1:
fill_color = color1
elif x >= x2:
fill_color = color2
else:
ratio = (x - x1) / (x2 - x1)
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
fill_color = (r, g, b)
draw.line([(x, 0), (x, height)], fill=fill_color)
else:
draw.rectangle([0, 0, width, height], fill=color1)
else: # 对角渐变(简化处理为左上到右下)
for y in range(height):
for x in range(width):
distance = (x/width + y/height) / 2
r = int(color1[0] + (color2[0] - color1[0]) * distance)
g = int(color1[1] + (color2[1] - color1[1]) * distance)
b = int(color1[2] + (color2[2] - color1[2]) * distance)
draw.point((x, y), fill=(r, g, b))
return gradient
def _create_radial_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
"""创建径向渐变"""
if self.has_numpy and len(color_nodes) > 2:
return self._create_radial_gradient_numpy(width, height, color_nodes)
else:
return self._create_simple_gradient(width, height, color_nodes)
def _create_radial_gradient_numpy(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
"""使用numpy创建径向渐变多色"""
# 创建坐标网格
x = np.linspace(0, 1, width)
y = np.linspace(0, 1, height)
xx, yy = np.meshgrid(x, y)
# 提取颜色和位置
positions = np.array([node['position'] for node in color_nodes])
colors = np.array([node['color'] for node in color_nodes])
# 计算每个点到所有节点的距离
distances = np.sqrt((xx[:, :, np.newaxis] - positions[np.newaxis, np.newaxis, :, 0]) ** 2 +
(yy[:, :, np.newaxis] - positions[np.newaxis, np.newaxis, :, 1]) ** 2)
# 找到最近的两个节点
sorted_indices = np.argsort(distances, axis=2)
nearest_idx = sorted_indices[:, :, 0]
second_idx = sorted_indices[:, :, 1]
# 获取对应的颜色
nearest_colors = colors[nearest_idx]
second_colors = colors[second_idx]
# 获取距离并计算权重
nearest_dist = np.take_along_axis(distances, np.expand_dims(nearest_idx, axis=2), axis=2)[:, :, 0]
second_dist = np.take_along_axis(distances, np.expand_dims(second_idx, axis=2), axis=2)[:, :, 0]
total_dist = nearest_dist + second_dist
mask = total_dist > 0
weight1 = np.zeros_like(nearest_dist)
weight1[mask] = second_dist[mask] / total_dist[mask]
weight2 = 1 - weight1
# 插值颜色
r = nearest_colors[:, :, 0] * weight1 + second_colors[:, :, 0] * weight2
g = nearest_colors[:, :, 1] * weight1 + second_colors[:, :, 1] * weight2
b = nearest_colors[:, :, 2] * weight1 + second_colors[:, :, 2] * weight2
gradient_array = np.stack([r, g, b], axis=-1).astype(np.uint8)
return Image.fromarray(gradient_array)
def _create_simple_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
"""创建简化渐变没有numpy或多色时使用"""
gradient = Image.new('RGB', (width, height))
draw = ImageDraw.Draw(gradient)
if len(color_nodes) >= 2:
# 使用第一个和最后一个颜色创建简单渐变
color1 = color_nodes[0]['color']
color2 = color_nodes[-1]['color']
# 判断节点分布
x_positions = [node['position'][0] for node in color_nodes]
y_positions = [node['position'][1] for node in color_nodes]
if all(abs(x - x_positions[0]) < 0.01 for x in x_positions):
# 垂直渐变
for y in range(height):
ratio = y / (height - 1) if height > 1 else 0
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
draw.line([(0, y), (width, y)], fill=(r, g, b))
else:
# 水平渐变
for x in range(width):
ratio = x / (width - 1) if width > 1 else 0
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
draw.line([(x, 0), (x, height)], fill=(r, g, b))
else:
# 单色
draw.rectangle([0, 0, width, height], fill=color_nodes[0]['color'])
return gradient
def create_simple_gradient(self, width: int, height: int,
start_color: Tuple, end_color: Tuple,
direction: str = 'vertical') -> Image.Image:
"""创建简单双色渐变
Args:
width: 图像宽度
height: 图像高度
start_color: 起始颜色
end_color: 结束颜色
direction: 渐变方向 'vertical', 'horizontal', 'diagonal'
Returns:
Image.Image: 渐变图像
"""
if direction == 'vertical':
return self._create_vertical_gradient(width, height, start_color, end_color)
elif direction == 'horizontal':
return self._create_horizontal_gradient(width, height, start_color, end_color)
else: # diagonal
return self._create_diagonal_gradient(width, height, start_color, end_color)
def _create_vertical_gradient(self, width: int, height: int,
color1: Tuple, color2: Tuple) -> Image.Image:
"""创建垂直渐变"""
gradient = Image.new('RGB', (width, height))
draw = ImageDraw.Draw(gradient)
for y in range(height):
ratio = y / (height - 1) if height > 1 else 0
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
draw.line([(0, y), (width, y)], fill=(r, g, b))
return gradient
def _create_horizontal_gradient(self, width: int, height: int,
color1: Tuple, color2: Tuple) -> Image.Image:
"""创建水平渐变"""
gradient = Image.new('RGB', (width, height))
draw = ImageDraw.Draw(gradient)
for x in range(width):
ratio = x / (width - 1) if width > 1 else 0
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
draw.line([(x, 0), (x, height)], fill=(r, g, b))
return gradient
def _create_diagonal_gradient(self, width: int, height: int,
color1: Tuple, color2: Tuple) -> Image.Image:
"""创建对角渐变"""
if self.has_numpy:
return self._create_diagonal_gradient_numpy(width, height, color1, color2)
else:
return self._create_horizontal_gradient(width, height, color1, color2) # 降级为水平渐变
def _create_diagonal_gradient_numpy(self, width: int, height: int,
color1: Tuple, color2: Tuple) -> Image.Image:
"""使用numpy创建对角渐变"""
x = np.linspace(0, 1, width)
y = np.linspace(0, 1, height)
xx, yy = np.meshgrid(x, y)
distance = (xx + yy) / 2.0
r = color1[0] + (color2[0] - color1[0]) * distance
g = color1[1] + (color2[1] - color1[1]) * distance
b = color1[2] + (color2[2] - color1[2]) * distance
gradient_array = np.stack([r, g, b], axis=-1).astype(np.uint8)
return Image.fromarray(gradient_array)

View File

@ -0,0 +1,182 @@
import asyncio
from dataclasses import dataclass
from hashlib import md5
import time
from nonebot import logger
from nonebot_plugin_apscheduler import driver
from konabot.common.path import DATA_PATH
import os
from PIL import Image
from io import BytesIO
IMAGE_PATH = DATA_PATH / "temp" / "images"
@dataclass
class ImageResource:
filename: str
expire: int
@dataclass
class StorageImage:
name: str
resources: dict[str,
dict[str,ImageResource]] # {群号: {QQ号: ImageResource}}
class ImageStorager:
images_pool: dict[str,StorageImage] = {}
max_storage: int = 10 * 1024 * 1024 # 最大存储10MB
max_image_count: int = 200 # 最大存储图片数量
@staticmethod
def init():
if not IMAGE_PATH.exists():
IMAGE_PATH.mkdir(parents=True, exist_ok=True)
@staticmethod
def delete_path_image(name: str):
resource_path = IMAGE_PATH / name
if resource_path.exists():
os.remove(resource_path)
@staticmethod
async def clear_all_image():
# 清理 temp 目录下的所有图片资源
for file in os.listdir(IMAGE_PATH):
file_path = IMAGE_PATH / file
if file_path.is_file():
os.remove(file_path)
@classmethod
async def clear_expire_image(cls):
# 清理过期的图片资源,将未被删除的放入列表中,如果超过最大数量则删除最早过期的
remaining_images = []
current_time = time.time()
for name, storage_image in list(ImageStorager.images_pool.items()):
for group_id, resources in list(storage_image.resources.items()):
for qq_id, resource in list(resources.items()):
if resource.expire < current_time:
del storage_image.resources[group_id][qq_id]
cls.delete_path_image(name)
else:
remaining_images.append((name, group_id, qq_id, resource.expire))
if not storage_image.resources:
del ImageStorager.images_pool[name]
# 如果剩余图片超过最大数量,按过期时间排序并删除最早过期的
if len(remaining_images) > ImageStorager.max_image_count:
remaining_images.sort(key=lambda x: x[3]) # 按过期时间排序
to_delete = len(remaining_images) - ImageStorager.max_image_count
for i in range(to_delete):
name, group_id, qq_id, _ = remaining_images[i]
resource = ImageStorager.images_pool[name].resources[group_id][qq_id]
del ImageStorager.images_pool[name].resources[group_id][qq_id]
cls.delete_path_image(name)
logger.info("过期图片清理完成")
@classmethod
def _add_to_pool(cls, filename: str, name: str, group_id: str, qq_id: str, expire: int = 36000):
expire_time = time.time() + expire
if name not in cls.images_pool:
cls.images_pool[name] = StorageImage(name=name,resources={})
if group_id not in cls.images_pool[name].resources:
cls.images_pool[name].resources[group_id] = {}
cls.images_pool[name].resources[group_id][qq_id] = ImageResource(filename=filename, expire=expire_time)
logger.debug(f"{cls.images_pool}")
@classmethod
def save_image(cls, image: bytes, name: str, group_id: str, qq_id: str) -> None:
"""
以哈希值命名保存图片,并返回图片资源信息
"""
# 检测图像大小,不得超过 10 MB
if len(image) > cls.max_storage:
raise ValueError("图片大小超过 10 MB 限制")
hash_name = md5(image).hexdigest()
ext = os.path.splitext(name)[1]
file_name = f"{hash_name}{ext}"
full_path = IMAGE_PATH / file_name
with open(full_path, "wb") as f:
f.write(image)
# 将文件写入 images_pool
logger.debug(f"Image saved: {file_name} for group {group_id}, qq {qq_id}")
cls._add_to_pool(file_name, name, group_id, qq_id)
@classmethod
def save_image_by_pil(cls, image: Image.Image, name: str, group_id: str, qq_id: str) -> None:
"""
以哈希值命名保存图片,并返回图片资源信息
"""
img_byte_arr = BytesIO()
# 如果图片是动图,保存为 GIF 格式
if getattr(image, "is_animated", False):
image.save(img_byte_arr, format="GIF", save_all=True, loop=0)
else:
image.save(img_byte_arr, format=image.format or "PNG")
img_bytes = img_byte_arr.getvalue()
cls.save_image(img_bytes, name, group_id, qq_id)
@classmethod
def load_image(cls, name: str, group_id: str, qq_id: str) -> Image:
logger.debug(f"Loading image: {name} for group {group_id}, qq {qq_id}")
if name not in cls.images_pool:
logger.debug(f"Image {name} not found in pool")
return None
if group_id not in cls.images_pool[name].resources:
logger.debug(f"No resources for group {group_id} in image {name}")
return None
# 寻找对应 QQ 号 的资源,如果没有就返回相同群下的第一个资源
if qq_id not in cls.images_pool[name].resources[group_id]:
first_qq_id = next(iter(cls.images_pool[name].resources[group_id]))
qq_id = first_qq_id
resource = cls.images_pool[name].resources[group_id][qq_id]
resource_path = IMAGE_PATH / resource.filename
logger.debug(f"Image path: {resource_path}")
return Image.open(resource_path)
class ImageStoragerManager:
def __init__(self, interval: int = 300): # 默认 5 分钟执行一次
self.interval = interval
self._clear_task = None
self._running = False
async def start_auto_clear(self):
"""启动自动任务"""
# 先清理一次
await ImageStorager.clear_all_image()
self._running = True
self._clear_task = asyncio.create_task(self._auto_clear_loop())
logger.info(f"自动清理任务已启动,间隔: {self.interval}")
async def stop_auto_clear(self):
"""停止自动清理任务"""
if self._clear_task:
self._running = False
self._clear_task.cancel()
try:
await self._clear_task
except asyncio.CancelledError:
pass
logger.info("自动清理任务已停止")
else:
logger.warning("没有正在运行的自动清理任务")
async def _auto_clear_loop(self):
"""自动清理循环"""
while self._running:
try:
await asyncio.sleep(self.interval)
await ImageStorager.clear_expire_image()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"定时清理失败: {e}")
image_manager = ImageStoragerManager(interval=300) # 每5分钟清理一次
@driver.on_startup
async def init_image_storage():
ImageStorager.init()
# 启用定时任务清理过期图片
await image_manager.start_auto_clear()

View File

@ -0,0 +1,125 @@
import cv2
from nonebot import logger
import numpy as np
from shapely.geometry import Polygon
from shapely.ops import unary_union
def fix_with_shapely(contours: list) -> np.ndarray:
"""
使用Shapely库处理复杂自相交
"""
fixed_polygons = []
for contour in contours:
# 转换输入为正确的格式
contour_array = contour.reshape(-1, 2)
# 转换为Shapely多边形
polygon = Polygon(contour_array)
if not polygon.is_valid:
polygon = polygon.buffer(0)
fixed_polygons.append(polygon)
# 接下来把所有轮廓合并为一个
if len(fixed_polygons) >= 1:
merged_polygon = unary_union(fixed_polygons)
if merged_polygon.geom_type == 'Polygon':
merged_points = np.array(merged_polygon.exterior.coords, dtype=np.int32)
elif merged_polygon.geom_type == 'MultiPolygon':
largest = max(merged_polygon.geoms, key=lambda p: p.area)
merged_points = np.array(largest.exterior.coords, dtype=np.int32)
return [merged_points.reshape(-1, 1, 2)]
else:
logger.warning("No valid contours found after fixing with Shapely.")
return [np.array([], dtype=np.int32).reshape(0, 1, 2)]
def expand_contours(contours, stroke_width):
"""
将轮廓向外扩展指定宽度
参数:
contours: OpenCV轮廓列表
stroke_width: 扩展宽度(像素)
返回:
扩展后的轮廓列表
"""
expanded_contours = []
for cnt in contours:
# 将轮廓转换为点列表
points = cnt.reshape(-1, 2).astype(np.float32)
n = len(points)
if n < 3:
continue # 至少需要3个点才能形成多边形
expanded_points = []
for i in range(n):
# 获取当前点、前一个点和后一个点
p_curr = points[i]
p_prev = points[(i - 1) % n]
p_next = points[(i + 1) % n]
# 计算两条边的向量
v1 = p_curr - p_prev # 前一条边从prev到curr
v2 = p_next - p_curr # 后一条边从curr到next
# 归一化
norm1 = np.linalg.norm(v1)
norm2 = np.linalg.norm(v2)
if norm1 == 0 or norm2 == 0:
# 如果有零向量,直接沿着法线方向扩展
edge_dir = np.array([0, 0])
if norm1 > 0:
edge_dir = v1 / norm1
elif norm2 > 0:
edge_dir = v2 / norm2
normal = np.array([-edge_dir[1], edge_dir[0]])
expanded_point = p_curr + normal * stroke_width
else:
# 归一化向量
v1_norm = v1 / norm1
v2_norm = v2 / norm2
# 计算两条边的单位法向量(指向多边形外部)
n1 = np.array([-v1_norm[1], v1_norm[0]])
n2 = np.array([-v2_norm[1], v2_norm[0]])
# 计算角平分线方向(两个法向量的和)
bisector = n1 + n2
# 计算平分线的长度
bisector_norm = np.linalg.norm(bisector)
if bisector_norm == 0:
# 如果两条边平行(同向或反向),取任一法线方向
expanded_point = p_curr + n1 * stroke_width
else:
# 归一化平分线
bisector_normalized = bisector / bisector_norm
# 计算偏移距离(考虑夹角)
# 使用余弦定理计算正确的偏移距离
cos_angle = np.dot(v1_norm, v2_norm)
angle = np.arccos(np.clip(cos_angle, -1.0, 1.0))
if abs(np.pi - angle) < 1e-6: # 近似平角
# 接近直线的情况
offset_distance = stroke_width
else:
# 计算正确的偏移距离
offset_distance = stroke_width / np.sin(angle / 2)
# 计算扩展点
expanded_point = p_curr + bisector_normalized * offset_distance
expanded_points.append(expanded_point)
# 将扩展后的点转换为整数坐标
expanded_cnt = np.array(expanded_points, dtype=np.float32).reshape(-1, 1, 2)
expanded_contours.append(expanded_cnt.astype(np.int32))
expanded_contours = fix_with_shapely(expanded_contours)
return expanded_contours

View File

@ -0,0 +1,23 @@
from dataclasses import dataclass
@dataclass
class FilterItem:
name: str
filter: callable
args: list
class ImageRequireSignal:
pass
class ImagesListRequireSignal:
pass
@dataclass
class StoredInfo:
name: str
@dataclass
class SenderInfo:
group_id: str
qq_id: str

View File

@ -0,0 +1,129 @@
from typing import cast
from loguru import logger
from nonebot import on_command
import nonebot
from nonebot.adapters import Event, Bot
from nonebot_plugin_alconna import UniMessage, UniMsg
from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent
from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot
from nonebot.adapters.onebot.v11.message import Message as OB11Message
from konabot.common.apis.ali_content_safety import AlibabaGreen
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.render_error_message import render_error_message
from konabot.plugins.handle_text.base import (
PipelineRunner,
TextHandlerEnvironment,
register_text_handlers,
)
from konabot.plugins.handle_text.handlers.ai_handlers import THQwen
from konabot.plugins.handle_text.handlers.encoding_handlers import (
THAlign,
THAlphaConv,
THB64Hex,
THBase64,
THBaseConv,
THCaesar,
THMorse,
THReverse,
)
from konabot.plugins.handle_text.handlers.random_handlers import THShuffle, THSorted
from konabot.plugins.handle_text.handlers.unix_handlers import (
THCat,
THEcho,
THReplace,
THRm,
)
from konabot.plugins.handle_text.handlers.whitespace_handlers import (
THLines,
THLTrim,
THRTrim,
THSqueeze,
THTrim,
)
cmd = on_command(cmd="textfx", aliases={"处理文字", "处理文本"})
@cmd.handle()
async def _(msg: UniMsg, evt: Event, bot: Bot, target: DepLongTaskTarget):
istream = ""
if isinstance(evt, OB11MessageEvent):
if evt.reply is not None:
istream = evt.reply.message.extract_plain_text()
else:
for seg in evt.get_message():
if seg.type == "reply":
msgid = seg.get("id")
if msgid is not None:
msg2data = await cast(OB11Bot, bot).get_msg(message_id=msgid)
istream = OB11Message(
msg2data.get("message")
).extract_plain_text()
script = msg.extract_plain_text().removeprefix("textfx").removeprefix("处理文字")
runner = PipelineRunner.get_runner()
res = runner.parse_pipeline(script)
if isinstance(res, str):
await target.send_message(res)
return
env = TextHandlerEnvironment(is_trusted=False)
results = await runner.run_pipeline(res, istream or None, env)
# 检查是否有错误
for r in results:
if r.code != 0:
message = f"处理指令时出现问题:{r.ostream}"
rendered = await render_error_message(message)
await target.send_message(rendered)
return
# 收集所有组的文本输出和附件
ostreams = [r.ostream for r in results if r.ostream is not None]
attachments = [r.attachment for r in results if r.attachment is not None]
if ostreams:
txt = "\n".join(ostreams)
err = await AlibabaGreen.detect(txt)
if not err:
await target.send_message(
"处理指令时出现问题:内容被拦截!请你检查你的内容是否合理!"
)
return
await target.send_message(txt, at=False)
for att in attachments:
await target.send_message(UniMessage.image(raw=att), at=False)
driver = nonebot.get_driver()
@driver.on_startup
async def _():
register_text_handlers(
THCat(),
THEcho(),
THRm(),
THShuffle(),
THReplace(),
THBase64(),
THCaesar(),
THReverse(),
THBaseConv(),
THAlphaConv(),
THB64Hex(),
THAlign(),
THSorted(),
THMorse(),
THQwen(),
THTrim(),
THLTrim(),
THRTrim(),
THSqueeze(),
THLines(),
)
logger.info(f"注册了 TextHandler{PipelineRunner.get_runner().handlers}")

View File

@ -0,0 +1,348 @@
import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from string import whitespace
from typing import cast
from loguru import logger
@dataclass
class TextHandlerEnvironment:
is_trusted: bool
buffers: dict[str, str] = field(default_factory=dict)
@dataclass
class TextHandleResult:
code: int
ostream: str | None
attachment: bytes | None = None
class TextHandler(ABC):
name: str = ""
keywords: list[str] = []
@abstractmethod
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult: ...
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {self.name} [{''.join(self.keywords)}]>"
class TextHandlerSync(TextHandler):
@abstractmethod
def handle_sync(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult: ...
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
def _hs():
return self.handle_sync(env, istream, args)
return await asyncio.to_thread(_hs)
@dataclass
class PipelineCommand:
handler: TextHandler
args: list[str]
# 新增重定向目标buffer key
redirect_target: str | None = None
# 新增:是否为追加模式 (>>)
redirect_append: bool = False
@dataclass
class Pipeline:
command_groups: list[list[PipelineCommand]] = field(default_factory=list)
"一个列表的列表,每一组之间的指令之间使用管道符连接,而不同组之间不会有数据流"
class PipelineParseStatus(Enum):
normal = 0
in_string = 1
in_string_to_escape = 2
off_string = 3
whitespaces = whitespace + ""
class PipelineRunner:
handlers: list[TextHandler]
def __init__(self) -> None:
self.handlers = []
@staticmethod
def get_runner():
if "singleton" not in PipelineRunner.__annotations__:
PipelineRunner.__annotations__["singleton"] = PipelineRunner()
return cast(PipelineRunner, PipelineRunner.__annotations__.get("singleton"))
def register(self, handler: TextHandler):
self.handlers.append(handler)
def parse_pipeline(self, script: str) -> Pipeline | str:
pipeline = Pipeline()
# 当前正在构建的上下文
current_group: list[PipelineCommand] = []
current_command_args: list[str] = []
# 字符串解析状态
status = PipelineParseStatus.normal
current_string = ""
current_string_raw = ""
status_in_string_pair = ""
has_token = False # 是否正在构建一个 token区分空字符串和无 token
# 重定向解析状态
is_parsing_redirect_filename = False
current_redirect_target: str | None = None
current_redirect_append = False
# 辅助函数:将当前解析到的字符串 flush 到 参数列表 或 重定向目标
def _flush_token():
nonlocal \
current_string, \
current_string_raw, \
is_parsing_redirect_filename, \
current_redirect_target, \
has_token
if not has_token:
return
if is_parsing_redirect_filename:
current_redirect_target = current_string
is_parsing_redirect_filename = False # 重定向文件名只取一个 token
else:
current_command_args.append(current_string)
current_string = ""
current_string_raw = ""
has_token = False
# 辅助函数:将当前指令 flush 到当前组
def _flush_command() -> str | None:
nonlocal \
current_command_args, \
current_redirect_target, \
current_redirect_append
if not current_command_args:
return None
cmd_name = current_command_args[0]
args = current_command_args[1:]
matched = [
h for h in self.handlers if cmd_name in h.keywords or cmd_name == h.name
]
if not matched:
return f"不存在名为 {cmd_name} 的函数"
if len(matched) > 1:
logger.warning(
f"指令能对应超过一个文本处理器 CMD={cmd_name} handlers={self.handlers}"
)
cmd = PipelineCommand(
handler=matched[0],
args=args,
redirect_target=current_redirect_target,
redirect_append=current_redirect_append,
)
current_group.append(cmd)
# 重置指令级状态
current_command_args = []
current_redirect_target = None
current_redirect_append = False
return None
# 使用索引遍历以支持 look-ahead (处理 >>)
i = 0
length = len(script)
while i < length:
c = script[i]
match status:
case PipelineParseStatus.normal:
if c in whitespaces:
_flush_token()
elif c in "'\"":
status_in_string_pair = c
status = PipelineParseStatus.in_string
current_string_raw = ""
has_token = True
elif c == "|":
_flush_token()
if err := _flush_command():
return err
# 管道符不结束 group继续在 current_group 添加
elif c == ";":
_flush_token()
if err := _flush_command():
return err
# 分号结束 group
if current_group:
pipeline.command_groups.append(current_group)
current_group = []
elif c == ">":
_flush_token() # 先结束之前的参数
# 检查是否是 append 模式 (>>)
if i + 1 < length and script[i + 1] == ">":
current_redirect_append = True
i += 1 # 跳过下一个 >
else:
current_redirect_append = False
# 标记下一个 token 为文件名
is_parsing_redirect_filename = True
else:
current_string += c
has_token = True
case PipelineParseStatus.in_string:
current_string_raw += c
if c == status_in_string_pair:
status = PipelineParseStatus.off_string
elif c == "\\":
status = PipelineParseStatus.in_string_to_escape
else:
current_string += c
case PipelineParseStatus.in_string_to_escape:
escape_map = {
"n": "\n",
"r": "\r",
"t": "\t",
"0": "\0",
"a": "\a",
"b": "\b",
"f": "\f",
"v": "\v",
"\\": "\\",
}
current_string += escape_map.get(c, c)
status = PipelineParseStatus.in_string
case PipelineParseStatus.off_string:
if c in whitespaces:
_flush_token()
status = PipelineParseStatus.normal
elif c == "|":
_flush_token()
if err := _flush_command():
return err
status = PipelineParseStatus.normal
elif c == ";":
_flush_token()
if err := _flush_command():
return err
if current_group:
pipeline.command_groups.append(current_group)
current_group = []
status = PipelineParseStatus.normal
elif c == ">":
_flush_token()
status = PipelineParseStatus.normal
# 回退索引,让下一次循环进入 normal 状态的 > 处理逻辑
i -= 1
else:
# 紧接着的字符继续作为当前字符串的一部分 (如 "abc"d)
current_string += c
current_string_raw = ""
status = PipelineParseStatus.normal
i += 1
# 循环结束后的收尾
_flush_token()
if err := _flush_command():
return err
if current_group:
pipeline.command_groups.append(current_group)
return pipeline
async def run_pipeline(
self,
pipeline: Pipeline,
istream: str | None,
env: TextHandlerEnvironment | None = None,
) -> list[TextHandleResult]:
if env is None:
env = TextHandlerEnvironment(is_trusted=False, buffers={})
results: list[TextHandleResult] = []
# 遍历执行指令组 (分号分隔),每个组独立产生输出
for group in pipeline.command_groups:
current_stream = istream
group_result = TextHandleResult(code=0, ostream=None)
# 遍历组内指令 (管道分隔)
for cmd in group:
try:
logger.debug(
f"Executing: {cmd.handler.name} args={cmd.args} redirect={cmd.redirect_target}"
)
result = await cmd.handler.handle(env, current_stream, cmd.args)
if result.code != 0:
# 组内出错,整条流水线中止
results.append(result)
return results
# 处理重定向逻辑
if cmd.redirect_target:
content_to_write = result.ostream or ""
target_buffer = cmd.redirect_target
if cmd.redirect_append:
old_content = env.buffers.get(target_buffer, "")
env.buffers[target_buffer] = old_content + content_to_write
else:
env.buffers[target_buffer] = content_to_write
current_stream = None
group_result = TextHandleResult(
code=0, ostream=None, attachment=result.attachment
)
else:
current_stream = result.ostream
group_result = result
except Exception as e:
logger.error(f"Pipeline execution failed at {cmd.handler.name}")
logger.exception(e)
results.append(
TextHandleResult(
code=-1, ostream="处理流水线时出现 python 错误"
)
)
return results
results.append(group_result)
return results
def register_text_handlers(*handlers: TextHandler):
for handler in handlers:
PipelineRunner.get_runner().register(handler)

View File

@ -0,0 +1,44 @@
from typing import Any, cast
from konabot.common.llm import get_llm
from konabot.plugins.handle_text.base import TextHandler, TextHandlerEnvironment, TextHandleResult
class THQwen(TextHandler):
name = "qwen"
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
llm = get_llm("qwen3-max")
messages = []
if istream is not None:
messages.append({
"role": "user",
"content": istream
})
if len(args) > 0:
message = ' '.join(args)
messages.append({
"role": "user",
"content": message,
})
if len(messages) == 0:
return TextHandleResult(
code=1,
ostream="使用方法qwen <提示词>",
)
messages = [{
"role": "system",
"content": "除非用户要求,请尽可能短点回答。另外,当前环境不支持 Markdown 语法,如果可以,请使用纯文本回答"
}] + messages
result = await llm.chat(cast(Any, messages))
content = result.content
if content is None:
return TextHandleResult(
code=500,
ostream="问 AI 的时候发生了未知的错误",
)
return TextHandleResult(
code=0,
ostream=content,
)

View File

@ -0,0 +1,346 @@
import base64
from konabot.plugins.handle_text.base import (
TextHandleResult,
TextHandler,
TextHandlerEnvironment,
)
class THBase64(TextHandler):
name = "b64"
keywords = ["base64"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
# 用法: b64 encode/decode [encoding] [text]
if not args and istream is None:
return TextHandleResult(
1, "用法b64 <encode|decode> [编码, 默认utf-8] [文本]"
)
mode = args[0].lower() if args else "encode"
encoding = args[1] if len(args) > 1 else "utf-8"
# 确定输入源
text = (
istream
if istream is not None
else (" ".join(args[2:]) if len(args) > 2 else "")
)
if not text:
return TextHandleResult(1, "输入文本为空")
try:
if mode == "encode":
res = base64.b64encode(text.encode(encoding, "replace")).decode("ascii")
else:
res = base64.b64decode(text.encode("ascii")).decode(encoding, "replace")
return TextHandleResult(0, res)
except Exception as e:
return TextHandleResult(1, f"Base64 转换失败: {str(e)}")
class THCaesar(TextHandler):
name = "caesar"
keywords = ["凯撒", "rot"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
# 用法: caesar <shift> [text]
shift = int(args[0]) if args else 13
text = (
istream
if istream is not None
else (" ".join(args[1:]) if len(args) > 1 else "")
)
def _shift(char):
if not char.isalpha():
return char
start = ord("A") if char.isupper() else ord("a")
return chr((ord(char) - start + shift) % 26 + start)
res = "".join(_shift(c) for c in text)
return TextHandleResult(0, res)
class THReverse(TextHandler):
name = "reverse"
keywords = ["rev", "反转"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
text = istream if istream is not None else (" ".join(args) if args else "")
return TextHandleResult(0, text[::-1])
class THMorse(TextHandler):
name = "morse"
keywords = ["摩斯", "decode_morse"]
# 国际摩斯电码表 (部分)
MORSE_EN = {
".-": "A",
"-...": "B",
"-.-.": "C",
"-..": "D",
".": "E",
"..-.": "F",
"--.": "G",
"....": "H",
"..": "I",
".---": "J",
"-.-": "K",
".-..": "L",
"--": "M",
"-.": "N",
"---": "O",
".--.": "P",
"--.-": "Q",
".-.": "R",
"...": "S",
"-": "T",
"..-": "U",
"...-": "V",
".--": "W",
"-..-": "X",
"-.--": "Y",
"--..": "Z",
"-----": "0",
".----": "1",
"..---": "2",
"...--": "3",
"....-": "4",
".....": "5",
"-....": "6",
"--...": "7",
"---..": "8",
"----.": "9",
"/": " ",
}
# 日文和文摩斯电码表 (Wabun Code)
MORSE_JP = {
"--.--": "",
".-": "",
"..-": "",
"-.---": "",
".-...": "",
".-..": "",
"-.-..": "",
"...-": "",
"-.--": "",
"----": "",
"-.-.-": "",
"--.-.": "",
"---.-": "",
".---.": "",
"---.": "",
"-.": "",
"..-.": "",
".--.": "",
".-.--": "",
"..-..": "",
".-.": "",
"-.-.": "",
"....": "",
"--.-": "",
"..--": "",
"-...": "",
"--..-": "",
"--..": "",
".": "",
"-..": "",
"-..-": "",
"..-.-": "",
"-": "",
"-...-": "",
"-..-.": "",
".--": "",
"-..--": "",
"--": "",
"...": "",
"--.": "",
"-.--.": "",
"---": "",
".-.-": "",
"-.-": "",
".-..-": "",
".--..": "",
".---": "",
".-.-.": "",
"-..-.--.": "",
"-..-.--": "",
"-..--..--": "",
"-..---": "",
"-..---.--": "",
"-..-.-": "",
"-..-..-": "",
"-..--.---": "",
"-..-.-...": "",
"-..-.-..": "",
"-..--.--": "",
"..": "",
"..--.": "",
".--.-": "",
".-.-.-": "",
".-.-..": "",
"-.--.-": "",
".-..-.": "",
}
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
"""
用法: morse <mode: en|jp> [text]
例子: morse en .... . .-.. .-.. ---
"""
if not args and istream is None:
return TextHandleResult(
1, "用法morse <en|jp> <电码>。使用空格分隔字符,/ 分隔单词。"
)
mode = args[0].lower() if args else "en"
text = (
istream
if istream is not None
else (" ".join(args[1:]) if len(args) > 1 else "")
)
if not text:
return TextHandleResult(1, "请输入电码内容")
# 选择词典
mapping = self.MORSE_JP if mode == "jp" else self.MORSE_EN
try:
# 按空格切分符号,过滤掉多余空位
tokens = [t for t in text.split(" ") if t]
decoded = []
for token in tokens:
# 处理部分解谜中可能出现的换行或特殊斜杠
token = token.strip()
if token in mapping:
decoded.append(mapping[token])
else:
decoded.append("[?]") # 无法识别的符号
return TextHandleResult(0, "".join(decoded))
except Exception as e:
return TextHandleResult(1, f"摩斯电码解析出错: {str(e)}")
class THBaseConv(TextHandler):
name = "baseconv"
keywords = ["进制转换"]
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
# 用法: baseconv <src_base> <dst_base> [text]
if len(args) < 2 and istream is None:
return TextHandleResult(1, "用法baseconv <原进制> <目标进制> [文本]")
src_base = int(args[0])
dst_base = int(args[1])
val_str = istream if istream is not None else "".join(args[2:])
try:
# 先转为 10 进制中间量,再转为目标进制
decimal_val = int(val_str, src_base)
if dst_base == 10:
res = str(decimal_val)
elif dst_base == 16:
res = hex(decimal_val)[2:]
else:
# 通用任意进制转换逻辑
chars = "0123456789abcdefghijklmnopqrstuvwxyz"
res = ""
temp = decimal_val
while temp > 0:
res = chars[temp % dst_base] + res
temp //= dst_base
res = res or "0"
return TextHandleResult(0, res.upper() if dst_base == 16 else res)
except Exception as e:
return TextHandleResult(1, f"转换失败: {str(e)}")
class THAlphaConv(TextHandler):
name = "alphaconv"
keywords = ["字母表转换"]
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
# 用法: alphaconv <alphabet> <to_hex|from_hex> [text]
if len(args) < 2:
return TextHandleResult(1, "用法alphaconv <字母表> <to_hex|from_hex> [文本]")
alphabet = args[0]
mode = args[1].lower()
base = len(alphabet)
text = istream if istream is not None else "".join(args[2:])
try:
if mode == "to_hex":
# 自定义字母表 -> 10进制 -> 16进制
val = 0
for char in text:
val = val * base + alphabet.index(char)
return TextHandleResult(0, hex(val)[2:])
else:
# 16进制 -> 10进制 -> 自定义字母表
val = int(text, 16)
res = ""
while val > 0:
res = alphabet[val % base] + res
val //= base
return TextHandleResult(0, res or alphabet[0])
except Exception as e:
return TextHandleResult(1, f"字母表转换失败: {str(e)}")
class THB64Hex(TextHandler):
name = "b64hex"
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
# 用法: b64hex <enc|dec> [text]
mode = args[0] if args else "dec"
text = istream if istream is not None else "".join(args[1:])
try:
if mode == "enc": # Hex -> B64
raw_bytes = bytes.fromhex(text)
res = base64.b64encode(raw_bytes).decode()
else: # B64 -> Hex
raw_bytes = base64.b64decode(text)
res = raw_bytes.hex()
return TextHandleResult(0, res)
except Exception as e:
return TextHandleResult(1, f"Base64-Hex 转换失败: {str(e)}")
class THAlign(TextHandler):
name = "align"
keywords = ["format", "排版"]
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
# 用法: align <n:每组长度> <m:每行组数> [text]
# 例子: align 2 8 (即 2个一组8组一行类似 0011 2233...)
n = int(args[0]) if len(args) > 0 else 2
m = int(args[1]) if len(args) > 1 else 8
text = istream if istream is not None else "".join(args[2:])
# 移除现有空格换行以便重新排版
text = "".join(text.split())
chunks = [text[i:i+n] for i in range(0, len(text), n)]
lines = []
for i in range(0, len(chunks), m):
lines.append(" ".join(chunks[i:i+m]))
return TextHandleResult(0, "\n".join(lines))

View File

@ -0,0 +1,37 @@
import random
from konabot.plugins.handle_text.base import TextHandleResult, TextHandler, TextHandlerEnvironment, TextHandlerSync
class THShuffle(TextHandler):
name: str = "shuffle"
keywords: list = ["打乱"]
async def handle(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
if istream is not None:
w = istream
elif len(args) == 0:
return TextHandleResult(1, "使用方法:打乱 <待打乱的文本>,或者使用管道符传入待打乱的文本")
else:
w = args[0]
args = args[1:]
w = [*w]
random.shuffle(w)
return TextHandleResult(0, ''.join(w))
class THSorted(TextHandlerSync):
name = "sort"
keywords = ["排序"]
def handle_sync(self, env: TextHandlerEnvironment, istream: str | None, args: list[str]) -> TextHandleResult:
if istream is not None:
w = istream
elif len(args) == 0:
return TextHandleResult(1, "使用方法:排序 <待排序的文本>,或者使用管道符传入待打乱的文本")
else:
w = args[0]
args = args[1:]
return TextHandleResult(0, ''.join(sorted([*w])))

View File

@ -0,0 +1,92 @@
import re
from konabot.plugins.handle_text.base import (
TextHandleResult,
TextHandler,
TextHandlerEnvironment,
)
class THEcho(TextHandler):
name = "echo"
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
if len(args) == 0 and istream is None:
return TextHandleResult(1, "请在 echo 后面添加需要输出的文本")
if istream is not None:
return TextHandleResult(0, "\n".join([istream] + args))
return TextHandleResult(0, "\n".join(args))
class THCat(TextHandler):
name = "cat"
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
# No args: pass through stdin (like Unix cat with no arguments)
if len(args) == 0:
if istream is None:
return TextHandleResult(
1,
"cat 使用方法cat [缓存名 ...]\n使用 - 代表标准输入,可拼接多个缓存",
)
return TextHandleResult(0, istream)
# Concatenate all specified sources in order
parts: list[str] = []
for arg in args:
if arg == "-":
if istream is None:
return TextHandleResult(2, "标准输入为空(没有管道输入或回复消息)")
parts.append(istream)
else:
if arg not in env.buffers:
return TextHandleResult(2, f"缓存 {arg} 不存在")
parts.append(env.buffers[arg])
return TextHandleResult(0, "\n".join(parts))
class THRm(TextHandler):
name = "rm"
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
if len(args) != 1:
return TextHandleResult(1, "rm 使用方法rm <缓存名>")
buf = args[0]
if buf == "-":
buf = istream
if buf not in env.buffers:
return TextHandleResult(2, f"缓存 {buf} 不存在")
del env.buffers[buf]
return TextHandleResult(0, None)
class THReplace(TextHandler):
name = "replace"
keywords = ["sed", "替换"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
# 用法: replace <pattern> <replacement> [text]
if len(args) < 2:
return TextHandleResult(1, "用法replace <正则> <替换内容> [文本]")
pattern, repl = args[0], args[1]
text = (
istream
if istream is not None
else (" ".join(args[2:]) if len(args) > 2 else "")
)
try:
res = re.sub(pattern, repl, text)
return TextHandleResult(0, res)
except Exception as e:
return TextHandleResult(1, f"正则错误: {str(e)}")

View File

@ -0,0 +1,126 @@
import re
from konabot.plugins.handle_text.base import (
TextHandleResult,
TextHandler,
TextHandlerEnvironment,
)
def _get_text(istream: str | None, args: list[str]) -> str | None:
"""从 istream 或 args 中获取待处理文本"""
if istream is not None:
return istream
if args:
return " ".join(args)
return None
class THTrim(TextHandler):
name = "trim"
keywords = ["strip", "去空格"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
text = _get_text(istream, args)
if text is None:
return TextHandleResult(1, "trim 使用方法trim [文本]\n去除首尾空白字符")
return TextHandleResult(0, text.strip())
class THLTrim(TextHandler):
name = "ltrim"
keywords = ["lstrip"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
text = _get_text(istream, args)
if text is None:
return TextHandleResult(1, "ltrim 使用方法ltrim [文本]\n去除左侧空白字符")
return TextHandleResult(0, text.lstrip())
class THRTrim(TextHandler):
name = "rtrim"
keywords = ["rstrip"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
text = _get_text(istream, args)
if text is None:
return TextHandleResult(1, "rtrim 使用方法rtrim [文本]\n去除右侧空白字符")
return TextHandleResult(0, text.rstrip())
class THSqueeze(TextHandler):
name = "squeeze"
keywords = ["压缩空白"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
text = _get_text(istream, args)
if text is None:
return TextHandleResult(
1, "squeeze 使用方法squeeze [文本]\n将连续空白字符压缩为单个空格"
)
return TextHandleResult(0, re.sub(r"[ \t]+", " ", text))
class THLines(TextHandler):
name = "lines"
keywords = ["行处理"]
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
# lines <子命令> [文本]
# 子命令: trim | empty | squeeze
if len(args) < 1:
return TextHandleResult(
1,
"lines 使用方法lines <子命令> [文本]\n"
"子命令:\n"
" trim - 去除每行首尾空白\n"
" empty - 去除所有空行\n"
" squeeze - 将连续空行压缩为一行",
)
subcmd = args[0]
text = (
istream
if istream is not None
else (" ".join(args[1:]) if len(args) > 1 else None)
)
if text is None:
return TextHandleResult(1, "请提供需要处理的文本(通过管道或参数)")
raw_lines = text.split("\n")
match subcmd:
case "trim":
result = "\n".join(line.strip() for line in raw_lines)
case "empty":
result = "\n".join(line for line in raw_lines if line.strip())
case "squeeze":
squeezed: list[str] = []
prev_empty = False
for line in raw_lines:
is_empty = not line.strip()
if is_empty:
if not prev_empty:
squeezed.append("")
prev_empty = True
else:
squeezed.append(line)
prev_empty = False
result = "\n".join(squeezed)
case _:
return TextHandleResult(
1, f"未知子命令:{subcmd}\n可用trim, empty, squeeze"
)
return TextHandleResult(0, result)

View File

@ -2,7 +2,6 @@ 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 (
@ -13,6 +12,10 @@ from nonebot_plugin_alconna import (
on_alconna,
)
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
from konabot.plugins.hanzi.er_data import ErFontData
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])
@ -25,6 +28,7 @@ def hanzi_to_abbr(hanzi: str) -> str:
"": "t",
"": "hk",
"": "jp",
"": "er",
}
return mapping.get(hanzi, "")
@ -35,6 +39,9 @@ def check_valid_convert_type(convert_type: str) -> bool:
return False
def convert(source, src_abbr, dst_abbr):
if dst_abbr == "er":
# 直接转换为二简
return ErFontData.convert_text(source)
convert_type_key = f"{src_abbr}2{dst_abbr}"
if not check_valid_convert_type(convert_type_key):
# 先转为繁体,再转为目标
@ -98,12 +105,11 @@ async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
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 = ["简体","簡體","繁體","繁体","正體","正体","港話","港话","日文"]
full_name_type = ["简体","簡體","繁體","繁体","正體","正体","港話","港话","日文","二简","二簡"]
combined_list = [f"{a}{b}" for a in shuo for b in full_name_type]
@ -151,20 +157,47 @@ async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
dst = ""
case "說日文" | "说日文":
dst = ""
case "說二簡" | "说二简" | "說二簡" | "说二簡":
dst = ""
dst_abbr = hanzi_to_abbr(dst)
if not dst_abbr:
notice = "不支持的转换类型,请使用“简体”、“繁體”、“正體”、“港話”、“日文”等。"
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)
# 如果是二简,直接转换
if dst_abbr == "er":
current_text = ErFontData.convert_text(to_convert)
else:
# 循环,将源语言一次次转换为目标语言
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())
if "span" in current_text:
# 改为网页渲染
render_result = await render_with_web_renderer(current_text)
await evt.send(await UniMessage().image(raw=render_result).export())
else:
await evt.send(await UniMessage().text(f"{converted_prefix}{current_text}").export())
async def render_with_web_renderer(text: str) -> bytes:
async def page_function(page):
# 找到id为content的文本框
await page.wait_for_selector('textarea[name=content]')
# 填入文本
await page.locator('textarea[name=content]').fill(text)
out = await WebRenderer.render_with_persistent_page(
"markdown_renderer",
konaweb('old_font'),
target='#main',
other_function=page_function,
)
return out
def random_char(char: str) -> str:
dst_abbr = random.choice(["s","t","hk","jp","tw"])
@ -214,4 +247,19 @@ async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
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())
await evt.send(await UniMessage().text(f"{converted_prefix}{final_text}").export())
def get_char(char: str, abbr: str) -> str:
output = ""
for src_abbr in ["s","hk","jp","tw","t"]:
if src_abbr != abbr:
output += convert(char, src_abbr, abbr)
return output
def get_all_variants(char: str) -> str:
output = ""
for abbr in ["s","hk","jp","tw","t"]:
for src_abbr in ["s","hk","jp","tw","t"]:
if src_abbr != abbr:
output += convert(char, src_abbr, abbr)
return output

View File

@ -0,0 +1,45 @@
import csv
from nonebot import logger
from nonebot_plugin_apscheduler import driver
from konabot.common.path import ASSETS_PATH
FONT_ASSETS_PATH = ASSETS_PATH / "old_font"
class ErFontData:
data = {}
temp_featured_fonts = {}
@classmethod
def init(cls):
logger.info("加载二简字体数据...")
path = FONT_ASSETS_PATH / "symtable.csv"
if not path.exists():
return
with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
if len(row["ss05"]) > 0:
cls.data[row["trad"]] = {"char": row["ss05"][0], "type": "ss05", "render": False}
if "er" in row["ss05"]:
cls.data[row["trad"]]["render"] = True
elif len(row["ss06"]) > 0:
cls.data[row["trad"]] = {"char": row["ss06"][0], "type": "ss06", "render": False}
if "er" in row["ss06"]:
cls.data[row["trad"]]["render"] = True
logger.info(f"二简字体数据加载完成,包含 {len(cls.data)} 个字。")
@classmethod
def get(cls, char: str) -> str:
if char not in cls.data:
return char
if cls.data[char]["render"]:
return f"<span class={cls.data[char]['type']}>{cls.data[char]['char']}</span>"
return cls.data[char]["char"]
@classmethod
def convert_text(cls, text: str) -> str:
return "".join([cls.get(c) for c in text])
@driver.on_startup
async def load_er_font_data():
ErFontData.init()

View File

@ -1,11 +1,12 @@
import asyncio as asynkio
import datetime
from io import BytesIO
import json
import secrets
from enum import Enum
from pathlib import Path
from typing import Optional
from PIL import Image
from loguru import logger
from nonebot import on_message
import nonebot
@ -617,14 +618,23 @@ async def _(event: BaseEvent, target: DepLongTaskTarget):
# 打开好吧狗本地文件
with open(ASSETS_PATH / "img" / "dog" / "haoba_dog.jpg", "rb") as f:
img_data = f.read()
# 把好吧狗变成 GIF 格式以缩小尺寸
img_data = await convert_image_to_gif(img_data)
await evt.send(await UniMessage().image(raw=img_data).export())
await end_game(event, group_id)
else:
await evt.send(
await UniMessage().text("当前没有成语接龙游戏在进行中!").export()
)
# await evt.send(
# await UniMessage().text("当前没有成语接龙游戏在进行中!").export()
# )
return
async def convert_image_to_gif(image_data: bytes) -> bytes:
with Image.open(BytesIO(image_data)) as img:
with BytesIO() as output:
img.save(output, format="GIF")
return output.getvalue()
# 跳过
evt = on_alconna(
Alconna("跳过成语"), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
@ -642,6 +652,8 @@ async def _(target: DepLongTaskTarget):
# 发送哈哈狗图片
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
img_data = f.read()
# 把哈哈狗变成 GIF 格式以缩小尺寸
img_data = await convert_image_to_gif(img_data)
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)

View File

@ -6,24 +6,11 @@ import PIL
import PIL.Image
import cv2
import imageio.v3 as iio
from nonebot import on_message
from nonebot.adapters import Bot
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna
import numpy
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.nb.extract_image import DepImageBytes, DepPILImage
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: DepPILImage, bot: Bot):
# 保持不变
await reply_image(cmd_black_white, bot, img.convert("LA"))
from konabot.common.nb.extract_image import DepImageBytes
# 保持不变

View File

@ -0,0 +1,46 @@
import re
from nonebot import on_message
from nonebot.rule import StartswithRule
from nonebot_plugin_alconna import At, Text, UniMessage, UniMsg
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
evt = on_message(rule=StartswithRule(("k8x12s",), True))
rule = re.compile(r"^[kK]8[xX]12[sS] ?(.+)$")
@evt.handle()
async def _(msg: UniMsg, target: DepLongTaskTarget):
if len(msg.include(Text, At)) != len(msg):
return
text = msg.extract_plain_text()
result = re.match(rule, text)
if result is None:
return
obj: str | None = result.group(1)
if obj is None:
return
img = await render_with_web_renderer(obj)
await target.send_message(UniMessage.image(raw=img), at=False)
async def render_with_web_renderer(text: str) -> bytes:
async def page_function(page):
await page.wait_for_selector('textarea[name=content]')
await page.locator('textarea[name=content]').fill(text)
out = await WebRenderer.render_with_persistent_page(
"markdown_renderer",
konaweb('k8x12S'),
target='#main',
other_function=page_function,
)
return out

View File

@ -5,6 +5,7 @@ import nonebot.adapters
import nonebot.rule
from nonebot import on_command
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
import playwright.async_api
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
@ -87,7 +88,7 @@ async def _(
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 = {**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:
@ -95,8 +96,12 @@ async def _(
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))
try:
img = await MarkDownCore.render_markdown(mans_msg)
await man.send(UniMessage.image(raw=img))
except (playwright.async_api.Error, ConnectionError):
# 图片渲染出错,改成发纯文本
await man.send(UniMessage.text(mans_msg))
help_deprecated = on_command('help', rule=nonebot.rule.to_me())

View File

@ -1,5 +1,4 @@
from loguru import logger
from playwright.async_api import ConsoleMessage, Page
from playwright.async_api import Page
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
@ -12,7 +11,7 @@ class MarkDownCore:
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 函数,确保渲染完成
@ -27,7 +26,7 @@ class MarkDownCore:
)
return out
@staticmethod
async def render_latex(text: str, theme: str = "dark") -> bytes:
params = {
@ -40,7 +39,7 @@ class MarkDownCore:
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 函数,确保渲染完成
@ -54,4 +53,4 @@ class MarkDownCore:
params=params
)
return out
return out

View File

@ -0,0 +1,57 @@
import asyncio
import mcstatus
from nonebot import on_command
from nonebot.adapters import Event
from nonebot_plugin_alconna import UniMessage
from konabot.common.nb.is_admin import is_admin
from mcstatus.responses import JavaStatusResponse
cmd = on_command("宾几人", aliases=set(("宾人数", "mcbingo")), rule=is_admin)
def parse_status(motd: str) -> str:
if "[PRE-GAME]" in motd:
return "[✨ 空闲]"
if "[IN-GAME]" in motd:
return "[🕜 游戏中]"
if "[POST-GAME]" in motd:
return "[🕜 游戏中]"
return "[✨ 开放]"
def dump_server_status(name: str, status: JavaStatusResponse | BaseException) -> str:
if isinstance(status, JavaStatusResponse):
motd = status.motd.to_plain()
# Bingo Status: [PRE-GAME], [IN-GAME], [POST-GAME]
st = parse_status(motd)
players_sample = status.players.sample or []
players_sample_suffix = ""
if len(players_sample) > 0:
player_list = [s.name for s in players_sample]
players_sample_suffix = " (" + ", ".join(player_list) + ")"
return f"{name}: {st} {status.players.online} 人在线{players_sample_suffix}"
else:
return f"{name}: 好像没开"
@cmd.handle()
async def _(evt: Event):
servers = (
(mcstatus.JavaServer("play.simpfun.cn", 11495), "小帕 Bingo"),
(mcstatus.JavaServer("bingo.mujica.tech"), "坏枪 Bingo"),
(mcstatus.JavaServer("mc.mujica.tech", 11456), "齿轮盛宴"),
)
responses = await asyncio.gather(
*map(lambda s: s[0].async_status(), servers),
return_exceptions=True,
)
messages = "\n".join((
dump_server_status(n, r)
for n, r in zip(map(lambda s: s[1], servers), responses)
))
await UniMessage.text(messages).finish(evt, at_sender=False)

View File

@ -0,0 +1,26 @@
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.notice_ui.notice import NoticeUI
from nonebot_plugin_alconna import on_alconna, Alconna, Args
evt = on_alconna(Alconna(
"notice",
Args["title", str],
Args["message", str]
),
use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
)
@evt.handle()
async def _(title: str, message: str, msg: UniMsg, event: BaseEvent):
logger.debug(f"Received notice command with title: {title}, message: {message}")
out = await NoticeUI.render_notice(title, message)
await evt.send(await UniMessage().image(raw=out).export())

View File

@ -0,0 +1,69 @@
from io import BytesIO
import random
from PIL import Image
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
import numpy as np
from playwright.async_api import Page
class NoticeUI:
@staticmethod
async def render_notice(title: str, message: str) -> bytes:
"""
渲染一个通知图片,包含标题和消息内容。
"""
async def page_function(page: Page):
# 直到 setMaskMode 函数加载完成
await page.wait_for_function("typeof setMaskMode === 'function'", timeout=1000)
await page.evaluate('setMaskMode(false)')
# 直到 setContent 函数加载完成
await page.wait_for_function("typeof setContent === 'function'", timeout=1000)
# 设置标题和消息内容
await page.evaluate("""([title, message]) => {
return setContent(title, message);
}""",
[title, message])
async def mask_function(page: Page):
# 直到 setContent 函数加载完成
await page.wait_for_function("typeof setContent === 'function'", timeout=1000)
# 设置标题和消息内容
await page.evaluate("""([title, message]) => {
return setContent(title, message);
}""",
[title, message])
# 直到 setMaskMode 函数加载完成
await page.wait_for_function("typeof setMaskMode === 'function'", timeout=1000)
await page.evaluate('setMaskMode(true)')
image_bytes = await WebRenderer.render_with_persistent_page(
"notice_renderer",
konaweb('notice'),
target='#main',
other_function=page_function,
)
mask_bytes = await WebRenderer.render_with_persistent_page(
"notice_renderer",
konaweb('notice'),
target='#main',
other_function=mask_function)
image = Image.open(BytesIO(image_bytes)).convert("RGBA")
mask = Image.open(BytesIO(mask_bytes)).convert("L")
# 遮罩抖动二值化
mask = mask.convert('1') # 先转换为1位图像
image.putalpha(mask)
# 保存为GIF
output_buffer = BytesIO()
image.save(
output_buffer,
format="GIF",
disposal=2
)
output_buffer.seek(0)
return output_buffer.getvalue()

View File

@ -1,10 +1,58 @@
from nonebot import on_message
from nonebot_plugin_alconna import UniMessage
from nonebot.internal.adapter import Event
from nonebot_plugin_alconna import UniMessage, UniMsg, Text
from konabot.common.nb.match_keyword import match_keyword
evt = on_message(rule=match_keyword(""))
evt_nya = on_message(rule=match_keyword(""))
@evt.handle()
@evt_nya.handle()
async def _():
await evt.send(await UniMessage().text("").export())
await evt_nya.send(await UniMessage().text("").export())
NYA_SYMBOL_MAPPING = {
"": "",
"!": "!",
"?": "!",
"": "",
"": "",
",": ",",
"": "",
".": ".",
"": "",
"": "",
"~": "~",
"": "",
" ": " ",
"\n": "\n",
}
async def has_nya(msg: UniMsg) -> bool:
if any((not isinstance(seg, Text) for seg in msg)):
return False
text = msg.extract_plain_text()
if len(text) <= 1:
return False
if "" not in text:
return False
if any(((char not in NYA_SYMBOL_MAPPING) for char in text)):
return False
return True
evt_nya_v2 = on_message(rule=has_nya)
@evt_nya_v2.handle()
async def _(msg: UniMsg, evt: Event):
text = msg.extract_plain_text()
await UniMessage.text(''.join(
(NYA_SYMBOL_MAPPING.get(c, '') for c in text)
)).send(evt)

View File

@ -0,0 +1,546 @@
import asyncio as asynkio
import datetime
import json
import secrets
import csv
import zipfile
from PIL import Image
from io import BytesIO
from enum import Enum
from pathlib import Path
from typing import Optional
from loguru import logger
from nonebot import on_message
import nonebot
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.database import DatabaseManager
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import ASSETS_PATH
from konabot.plugins.hanzi import get_char
ROOT_PATH = Path(__file__).resolve().parent
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
DATA_FILE_PATH = (
DATA_DIR / "oracle_banned.json"
)
# 创建全局数据库管理器实例
db_manager = DatabaseManager()
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_oracle_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")
driver = nonebot.get_driver()
@driver.on_startup
async def register_startup_hook():
"""注册启动时需要执行的函数"""
await oracleGame.init_lexicon()
@driver.on_shutdown
async def register_shutdown_hook():
"""注册关闭时需要执行的函数"""
# 关闭所有数据库连接
await db_manager.close_all_connections()
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
NOT_ORACLE = 1
HINT_ONE = 2
HINT_TWO = 3
GAME_END = 4
class oracleGame:
ALL_ORACLES = {}
INSTANCE_LIST: dict[str, "oracleGame"] = {} # 群号对应的游戏实例
__inited = False
def __init__(self, group_id: str):
# 初始化一局游戏
self.group_id = ""
self.now_playing = False
self.score_board = {}
self.remain_playing_times = 3
self.last_play_date = ""
self.all_buff_score = 0
self.lock = asynkio.Lock()
self.remain_rounds = 0 # 剩余回合数
self.current_oracle_id = ""
self.wrong_attempts = 0
oracleGame.INSTANCE_LIST[group_id] = self
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 get_oracle_image(self) -> bytes:
IMAGE_PATH = ASSETS_PATH / "oracle" / "image"
with open(IMAGE_PATH / self.ALL_ORACLES[self.current_oracle_id]["image"], "rb") as f:
img_data = f.read()
return img_data
def get_oracle_name(self) -> str:
return self.ALL_ORACLES.get(self.current_oracle_id, {}).get("oracle", "?")[0]
@staticmethod
async def random_oracle() -> str:
return secrets.choice(list(oracleGame.ALL_ORACLES.keys()))
async def choose_start_oracle(self) -> str:
"""
随机选择一个甲骨文作为起始甲骨文
"""
self.current_oracle_id = await oracleGame.random_oracle()
return self.current_oracle_id
@classmethod
async def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
await 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
async def start_game(self, rounds: int = 100):
self.now_playing = True
self.remain_rounds = rounds
await self.choose_start_oracle()
@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.wrong_attempts = 0
self.score_board = {}
self.all_buff_score = 0
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_oracle(self, buff_score: int = -100) -> str:
"""
跳过当前甲骨文,选择下一个甲骨文
"""
async with self.lock:
await self._skip_oracle_async()
self.add_buff_score(buff_score)
return self.current_oracle_id
async def _skip_oracle_async(self) -> str:
self.wrong_attempts = 0
self.current_oracle_id = await oracleGame.random_oracle()
return self.current_oracle_id
async def try_verify_oracle(self, oracle: str, user_id: str) -> list[TryVerifyState]:
"""
用户发送甲骨文
"""
async with self.lock:
state = await self._verify_oracle(oracle, user_id)
return state
async def _verify_oracle(self, oracle: str, user_id: str) -> list[TryVerifyState]:
state = []
if oracle.strip() not in self.ALL_ORACLES[self.current_oracle_id].get("oracle", ""):
state.append(TryVerifyState.NOT_ORACLE)
self.wrong_attempts += 1
if self.wrong_attempts == 5:
state.append(TryVerifyState.HINT_ONE)
elif self.wrong_attempts == 10:
state.append(TryVerifyState.HINT_TWO)
return state
if oracle.strip() == "":
return [TryVerifyState.NOT_ORACLE]
# 甲骨文合法,更新状态
self.wrong_attempts = 0
state.append(TryVerifyState.VERIFIED)
self.add_score(user_id, 1) # 加 1 分
self.remain_rounds -= 1
if self.remain_rounds <= 0:
self.now_playing = False
state.append(TryVerifyState.GAME_END)
else:
await self._skip_oracle_async()
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_pinyin_hint(self) -> str:
return self.ALL_ORACLES[self.current_oracle_id].get("pinyin", "")
def get_meaning_hint(self) -> str:
return self.ALL_ORACLES[self.current_oracle_id].get("meaning", "")
@classmethod
async def init_lexicon(cls):
if cls.__inited:
return
cls.__inited = True
# 加载甲骨文
ORACLE_DATA_PATH = ASSETS_PATH / "oracle"
with open(ORACLE_DATA_PATH / "zi_dict.csv", "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
# 以“子字头”为key释文为value构建字典
for row in reader:
char = row["子字头"].strip()
oracle = row["释文"].strip()
img_path = row.get("路径", "").strip()
cls.ALL_ORACLES[char] = {
"oracle": oracle,
"image": img_path,
"pinyin": row.get("拼音", "").strip(),
"meaning": row.get("含义", "").strip(),
}
logger.info(f"加载甲骨文字典,共计 {len(cls.ALL_ORACLES)} 条记录")
# 解包图片资源
IMAGE_PATH = ASSETS_PATH / "oracle" / "image"
if not IMAGE_PATH.exists():
IMAGE_PATH.mkdir(parents=True, exist_ok=True)
# 将 image.zip 解压到 IMAGE_PATH
if (ASSETS_PATH / "oracle" / "image.zip").exists():
with zipfile.ZipFile(ASSETS_PATH / "oracle" / "image.zip", "r") as zip_ref:
zip_ref.extractall(IMAGE_PATH)
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_oracle_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 = await oracleGame.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 = oracleGame.INSTANCE_LIST[group_id]
await instance.start_game(rounds)
# 发布甲骨文
await evt.send(
await UniMessage()
.image(raw=instance.get_oracle_image())
.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 = oracleGame.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"
)
await evt.send(await result_text.export())
# instance.clear_score_board()
# 将实例删除
del oracleGame.INSTANCE_LIST[group_id]
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 = oracleGame.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()
# 把好吧狗变成 GIF 格式以缩小尺寸
img_data = await convert_image_to_gif(img_data)
await evt.send(await UniMessage().image(raw=img_data).export())
await end_game(event, group_id)
else:
# await evt.send(
# await UniMessage().text("当前没有甲骨文游戏在进行中!").export()
# )
return
async def convert_image_to_gif(image_data: bytes) -> bytes:
with Image.open(BytesIO(image_data)) as img:
with BytesIO() as output:
img.save(output, format="GIF")
return output.getvalue()
# 跳过
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 = oracleGame.INSTANCE_LIST.get(group_id)
if not instance or not instance.get_playing_state():
return
# 发送哈哈狗图片
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
img_data = f.read()
# 把哈哈狗变成 GIF 格式以缩小尺寸
img_data = await convert_image_to_gif(img_data)
oracle = instance.get_oracle_name()
await evt.send(await UniMessage().image(raw=img_data).export())
await evt.send(await UniMessage().text(f"你们太菜了全部扣100分这个甲骨文是「{oracle}」!").export())
oracle = await instance.skip_oracle(-100)
await evt.send(
await UniMessage()
.image(raw=instance.get_oracle_image())
.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 = oracleGame.INSTANCE_LIST.get(group_id)
if not instance or not instance.get_playing_state():
return
user_oracle = msg.extract_plain_text().strip()
# 甲骨文应该是单个汉字
if len(user_oracle) != 1:
return
user_id, user_name = get_user_info(event)
state = await instance.try_verify_oracle(user_oracle, user_id)
if TryVerifyState.HINT_ONE in state:
hint_pinyin = instance.get_pinyin_hint()
await evt.send(
await UniMessage()
.text(f"提示:这个甲骨文的拼音是「{hint_pinyin}")
.export()
)
if TryVerifyState.HINT_TWO in state:
hint_meaning = instance.get_meaning_hint()
await evt.send(
await UniMessage()
.text(f"提示:这个甲骨文的含义是「{hint_meaning}")
.export()
)
if TryVerifyState.NOT_ORACLE in state:
return
if TryVerifyState.VERIFIED in state:
await evt.send(
await UniMessage()
.at(user_id)
.text(" 答对了!获得 1 分!")
.export()
)
if TryVerifyState.GAME_END in state:
await evt.send(await UniMessage().text("全部回合结束!").export())
await end_game(event, group_id)
return
await evt.send(
await UniMessage()
.image(raw=instance.get_oracle_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):
# 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())

View File

@ -0,0 +1,59 @@
import csv
import os
import sys
from loguru import logger
# 获取当前文件所在目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 向上两级到 konabot 目录
konabot_dir = os.path.abspath(os.path.join(current_dir, '../../../'))
if konabot_dir not in sys.path:
sys.path.insert(0, konabot_dir)
from konabot.common.path import ASSETS_PATH
ORACLE_PATH = ASSETS_PATH / "oracle"
final_zi_dict = {}
with open(ORACLE_PATH / "zi_dict.csv", "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
logger.info(f"Progress: {reader.line_num}")
# 找到子字头字段,并找到对应的图像路径
char = row["子字头"].strip()
# 寻找路径
image_path = ORACLE_PATH / "image"
# 遍历所有子目录,寻找对应的图片文件
found_image = None
for subdir in image_path.iterdir():
if subdir.is_dir():
candidate = subdir / char
if candidate.exists():
# 寻找该目录下有没有以 char 命名的图片文件,没有就选第一个图片文件
if (candidate / f"{char}.png").exists():
found_image = candidate / f"{char}.png"
break
else:
for file in candidate.iterdir():
if file.suffix.lower() in [".png", ".jpg", ".jpeg", ".gif", ".bmp"]:
found_image = file
break
if found_image is not None:
# 提取相对路径
found_image = found_image.relative_to(ORACLE_PATH / "image")
# 反斜杠改正为斜杠
found_image = found_image.as_posix()
# 更新行数据
row.update({"路径": str(found_image)})
final_zi_dict[char] = row
# 将最终的字典写入新的 CSV 文件
with open(ORACLE_PATH / "zi_dict_with_images.csv", "w", encoding="utf-8-sig", newline="") as f:
fieldnames = list(final_zi_dict[next(iter(final_zi_dict))].keys())
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for char, data in final_zi_dict.items():
writer.writerow(data)

View File

@ -1,4 +1,3 @@
import re
import aiohttp
import asyncio as asynkio
from math import ceil
@ -6,6 +5,8 @@ from pathlib import Path
from typing import Any
import nanoid
from nonebot.rule import KeywordsRule, Rule
from konabot.plugins.notice_ui.notice import NoticeUI
import nonebot
from loguru import logger
from nonebot import get_plugin_config, on_message
@ -14,10 +15,9 @@ from nonebot_plugin_alconna import Alconna, Args, Subcommand, UniMessage, UniMsg
from pydantic import BaseModel
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
from konabot.common.nb.match_keyword import match_keyword
from konabot.plugins.simple_notify.ask_llm import ask_ai
evt = on_message(rule=match_keyword(re.compile("^.+提醒我.+$")))
evt = on_message(rule=Rule(KeywordsRule("提醒我")))
(Path(__file__).parent.parent.parent.parent / "data").mkdir(exist_ok=True)
DATA_FILE_PATH = Path(__file__).parent.parent.parent.parent / "data" / "notify.json"
@ -95,7 +95,7 @@ async def _(msg: UniMsg, mEvt: Event, target: DepLongTaskTarget):
await target.send_message(
UniMessage().text(f"了解啦!将会在 {target_time.strftime(FMT_STRING)} 提醒你哦~")
)
logger.info(f"创建了一条于 {target_time}办提醒")
logger.info(f"创建了一条于 {target_time}办提醒")
driver = nonebot.get_driver()
@ -105,7 +105,12 @@ driver = nonebot.get_driver()
async def _(task: LongTask):
message = task.data["message"]
await task.target.send_message(
UniMessage().text(f"办提醒:{message}")
UniMessage().text(f"办提醒:{message}")
)
notice_bytes = await NoticeUI.render_notice("待办提醒", message)
await task.target.send_message(
UniMessage().image(raw=notice_bytes),
at=False
)
async with DATA_FILE_LOCK:
data = load_notify_config()
@ -118,7 +123,7 @@ USER_CHECKOUT_TASK_CACHE: dict[str, dict[str, str]] = {}
cmd_check_notify_list = on_alconna(Alconna(
"re:(?:我有哪些|查询)(?:提醒|办)",
"re:(?:我有哪些|查询)(?:提醒|办)",
Args["page", int, 1]
))
@ -136,7 +141,7 @@ async def _(page: int, target: DepLongTaskTarget):
await target.send_message(UniMessage().text(f"最多也就 {pages} 页啦!"))
tasks = tasks[(page - 1) * PAGE_SIZE: page * PAGE_SIZE]
message = "你可以输入「删除提醒 序号」来删除一个提醒\n====== 办清单 ======\n\n"
message = "你可以输入「删除提醒 序号」来删除一个提醒\n====== 办清单 ======\n\n"
to_cache = {}
if len(tasks) == 0:
@ -153,7 +158,7 @@ async def _(page: int, target: DepLongTaskTarget):
cmd_remove_task = on_alconna(Alconna(
"re:删除(?:提醒|办)",
"re:删除(?:提醒|办)",
Args["checker", str],
))
@ -230,7 +235,7 @@ async def _(target: DepLongTaskTarget, notify_id: str = ""):
))
await send_notify_to_ntfy_instance(
"如果你看到这条消息,说明你已经成功订阅主题!此方 BOT 将会在这里提醒你你的办!",
"如果你看到这条消息,说明你已经成功订阅主题!此方 BOT 将会在这里提醒你你的办!",
channel_name,
)

View File

@ -0,0 +1,101 @@
from borax.calendars import LunarDate
from nonebot import on_command
from nonebot.internal.adapter.event import Event
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_apscheduler import scheduler
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
from konabot.plugins.poster.service import broadcast
register_poster_info(
"二十四节气",
PosterInfo(
{"节气", "24节气"},
"当有新的节气时,报告节气信息",
),
)
# 二十四节气的内置口号
# Generated by claude-opus-4.6
SOLAR_TERM_SLOGANS: dict[str, str] = {
"立春": "春回大地,万物复苏!",
"雨水": "春雨绵绵,润物无声!",
"惊蛰": "春雷惊蛰,万物生长!",
"春分": "昼夜平分,春意盎然!",
"清明": "清明时节,踏青赏春!",
"谷雨": "谷雨时节,播种希望!",
"立夏": "立夏之日,夏意渐浓!",
"小满": "小满时节,麦穗渐满!",
"芒种": "芒种农忙,收获在望!",
"夏至": "夏至日长,骄阳似火!",
"小暑": "小暑炎炎,清凉为伴!",
"大暑": "大暑酷热,防暑降温!",
"立秋": "立秋时节,暑去凉来!",
"处暑": "处暑时节,秋高气爽!",
"白露": "白露降临,秋意渐浓!",
"秋分": "秋分时节,硕果累累!",
"寒露": "寒露凝结,秋意正浓!",
"霜降": "霜降时节,秋收冬藏!",
"立冬": "立冬之日,冬意渐起!",
"小雪": "小雪飘飘,寒意渐浓!",
"大雪": "大雪纷飞,银装素裹!",
"冬至": "冬至日短,数九寒天!",
"小寒": "小寒时节,天寒地冻!",
"大寒": "大寒岁末,辞旧迎新!",
}
@scheduler.scheduled_job("cron", hour="8")
async def _():
today = LunarDate.today()
term: str | None = today.term
if term is not None:
slogan = SOLAR_TERM_SLOGANS.get(term, "")
await broadcast(
"二十四节气", UniMessage.text(f"【今日节气】今天是 {term} 哦!{slogan}")
)
cmd_next_term = on_command("下一个节气")
@cmd_next_term.handle()
async def _(event: Event):
date = LunarDate.today()
day_counter = 0
while date.term is None:
date = date.after(day_delta=1)
day_counter += 1
if day_counter > 365:
await UniMessage.text("哇呀...查询出错了!").send(event)
return
d_cn_format = date.strftime("%M月%D") # 相当于正月初一这样的格式
date_solar = date.to_solar_date()
d_glob_format = f"{date_solar.month}{date_solar.day}"
msg = UniMessage.text(
f"下一个节气是{date.term},在 {day_counter} 天后的 {d_glob_format}(农历{d_cn_format}"
)
await msg.send(event)
cmd_current_term = on_command("当前节气", aliases={"获取节气", "节气"})
@cmd_current_term.handle()
async def _(event: Event):
date = LunarDate.today()
day_counter = 0
while date.term is None:
date = date.before(day_delta=1)
day_counter += 1
if day_counter > 365:
await UniMessage.text("哇呀...查询出错了!").send(event)
return
msg = UniMessage.text(f"现在的节气是{date.term}")
await msg.send(event)

View File

@ -0,0 +1,33 @@
import asyncio
from nonebot import get_driver
from nonebot_plugin_alconna import UniMessage
from konabot.plugins.poster.poster_info import register_poster_info, PosterInfo
from konabot.plugins.poster.service import broadcast
CHANNEL_STARTUP = "启动通知"
register_poster_info(CHANNEL_STARTUP, PosterInfo(
aliases=set(),
description="当 Bot 重启时告知",
))
driver = get_driver()
@driver.on_startup
async def _():
# 要尽量保证接受讯息的服务存在
# 所以在这里我们要等待一定时间后再发信
async def task():
while True:
if len(driver.bots) >= 1:
break
await asyncio.sleep(15)
# 在这个时候,需求的 bot 已经上线,再等待一小会
await asyncio.sleep(3)
await broadcast(CHANNEL_STARTUP, UniMessage.text("此方 BOT 重启好了"))
asyncio.create_task(task())

View File

@ -8,7 +8,7 @@ import functools
from loguru import logger
from nonebot import on_message
from nonebot.rule import KeywordsRule, Rule, ToMeRule
from nonebot.rule import KeywordsRule, Rule
from nonebot.adapters.onebot.v11.bot import Bot as OBBot
from nonebot.adapters.onebot.v11.event import MessageEvent as OBMessageEvent
from nonebot.adapters.onebot.v11.message import MessageSegment as OBMessageSegment
@ -21,9 +21,10 @@ from konabot.common.apis.ali_content_safety import AlibabaGreen
keywords = ("szmtq", "tqszm", "提取首字母", "首字母提取", )
rule = Rule(KeywordsRule(*keywords))
cmd_tqszm = on_message(rule=Rule(ToMeRule(), KeywordsRule(*keywords)))
cmd_tqszm = on_message(rule=rule)
@cmd_tqszm.handle()
async def _(target: DepLongTaskTarget, msg: UniMsg, evt: OBMessageEvent | None = None, bot: OBBot | None = None):

View File

@ -0,0 +1,108 @@
import asyncio
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import cast
from loguru import logger
from nonebot import on_command
from nonebot.adapters import Event, Bot
from nonebot_plugin_alconna import UniMessage, UniMsg
from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent
from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot
from nonebot.adapters.onebot.v11.message import Message as OB11Message
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import TMP_PATH
TEMPLATE_PATH = Path(__file__).parent / "template.typ"
TEMPLATE = TEMPLATE_PATH.read_text()
def render_sync(code: str) -> bytes:
with TemporaryDirectory(dir=TMP_PATH) as tmpdirname:
temp_dir = Path(tmpdirname).resolve()
temp_typ = temp_dir / "page.typ"
temp_typ.write_text(TEMPLATE + "\n\n" + code)
cmd = [
"typst",
"compile",
temp_typ.name,
"--format",
"png",
"--root",
temp_dir,
"--ppi",
"300",
]
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=temp_dir.resolve(), timeout=50
)
logger.info(
f"渲染了 Typst "
f"STDOUT={result.stdout} "
f"STDERR={result.stderr} "
f"RETURNCODE={result.returncode}"
)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, cmd, result.stdout, result.stderr
)
result_png = temp_dir / "page.png"
if not result_png.exists():
raise FileNotFoundError("Typst 没有输出图片文件")
return result_png.read_bytes()
async def render(code: str) -> bytes:
task = asyncio.to_thread(lambda: render_sync(code))
return await task
cmd = on_command("typst")
@cmd.handle()
async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget):
typst_code = ""
if isinstance(evt, OB11MessageEvent):
if evt.reply is not None:
typst_code = evt.reply.message.extract_plain_text()
else:
for seg in evt.get_message():
if seg.type == "reply":
msgid = seg.get("id")
if msgid is not None:
msg2data = await cast(OB11Bot, bot).get_msg(message_id=msgid)
typst_code = OB11Message(
msg2data.get("message")
).extract_plain_text()
typst_code += msg.extract_plain_text().removeprefix("typst").strip()
if len(typst_code) == 0:
return
try:
res = await render(typst_code)
except FileNotFoundError as e:
await target.send_message("渲染出错:内部错误")
raise e from e
except subprocess.CalledProcessError as e:
await target.send_message("渲染出错,以下是输出消息:\n\n" + e.stderr)
return
except TimeoutError:
await target.send_message("渲染出错:渲染超时")
return
except PermissionError as e:
await target.send_message("渲染出错:内部错误")
raise e from e
await target.send_message(UniMessage.image(raw=res), at=False)

View File

@ -0,0 +1,5 @@
#import "@preview/cetz:0.4.2"
#set page(width: auto, height: auto, margin: (x: 10pt, y: 10pt))
#set text(font: ("Noto Sans CJK SC"))

161
poetry.lock generated
View File

@ -569,6 +569,23 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "asyncio-dgram"
version = "3.0.0"
description = "Higher level Datagram support for Asyncio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "asyncio_dgram-3.0.0-py3-none-any.whl", hash = "sha256:a4113061e6a7fbeee928d49c56cb61b68ca4a2fbee37f7e97280bbc72323ba8e"},
{file = "asyncio_dgram-3.0.0.tar.gz", hash = "sha256:bd0937807a44451d799573b32400702187fd0bba6136f7cf306e9327c0c20f3e"},
]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "attrs"
version = "25.4.0"
@ -733,6 +750,22 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "borax"
version = "4.1.3"
description = "A tool collections.(Chinese-Lunar-Calendars/Python-Patterns)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "borax-4.1.3-py3-none-any.whl", hash = "sha256:bdba9abe1c3be4ba1b6a014b3b4a97a7ba254a08c7f40cd4428a13b84db02558"},
]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "brotli"
version = "1.2.0"
@ -1284,6 +1317,32 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "dnspython"
version = "2.8.0"
description = "DNS toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
{file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
]
[package.extras]
dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"]
dnssec = ["cryptography (>=45)"]
doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"]
doq = ["aioquic (>=1.2.0)"]
idna = ["idna (>=3.10)"]
trio = ["trio (>=0.30)"]
wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "exceptiongroup"
version = "1.3.0"
@ -2253,6 +2312,27 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "mcstatus"
version = "12.2.1"
description = "A library to query Minecraft Servers for their status and capabilities."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcstatus-12.2.1-py3-none-any.whl", hash = "sha256:9da6b8b6c1a43521f13cc50f312501091c1e7437c3ad2d65b9ccb534d70b1244"},
{file = "mcstatus-12.2.1.tar.gz", hash = "sha256:0c6fb84c96685bb02189951ba895afbaca22b65bfe74e7fc55ef73a1c6e8a9ce"},
]
[package.dependencies]
asyncio-dgram = ">=2.1.2"
dnspython = ">=2.4.2"
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
@ -4046,6 +4126,85 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "shapely"
version = "2.1.2"
description = "Manipulation and analysis of geometric objects"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f"},
{file = "shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea"},
{file = "shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f"},
{file = "shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142"},
{file = "shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4"},
{file = "shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0"},
{file = "shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e"},
{file = "shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f"},
{file = "shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618"},
{file = "shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d"},
{file = "shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09"},
{file = "shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26"},
{file = "shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7"},
{file = "shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2"},
{file = "shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6"},
{file = "shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc"},
{file = "shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94"},
{file = "shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359"},
{file = "shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3"},
{file = "shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b"},
{file = "shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc"},
{file = "shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d"},
{file = "shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454"},
{file = "shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179"},
{file = "shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8"},
{file = "shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a"},
{file = "shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e"},
{file = "shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6"},
{file = "shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af"},
{file = "shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd"},
{file = "shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350"},
{file = "shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715"},
{file = "shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40"},
{file = "shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b"},
{file = "shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801"},
{file = "shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0"},
{file = "shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c"},
{file = "shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99"},
{file = "shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf"},
{file = "shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c"},
{file = "shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223"},
{file = "shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c"},
{file = "shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df"},
{file = "shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf"},
{file = "shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4"},
{file = "shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc"},
{file = "shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566"},
{file = "shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c"},
{file = "shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a"},
{file = "shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076"},
{file = "shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1"},
{file = "shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0"},
{file = "shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26"},
{file = "shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0"},
{file = "shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735"},
{file = "shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9"},
{file = "shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9"},
]
[package.dependencies]
numpy = ">=1.21"
[package.extras]
docs = ["matplotlib", "numpydoc (==1.1.*)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"]
test = ["pytest", "pytest-cov", "scipy-doctest"]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "skia-python"
version = "138.0"
@ -4983,4 +5142,4 @@ reference = "mirrors"
[metadata]
lock-version = "2.1"
python-versions = ">=3.12,<4.0"
content-hash = "59498c038a603c90f051d2f360cb9226ec0fc4470942c0a7cf34f832701f0ce7"
content-hash = "15e51d7d14d091295e7d0ecabaa601fd65ae392fce28f90d5f3deb4718544e17"

View File

@ -31,6 +31,9 @@ dependencies = [
"sqlparse (>=0.5.0,<1.0.0)",
"alibabacloud-green20220302 (>=3.0.1,<4.0.0)",
"pypinyin (>=0.55.0,<0.56.0)",
"shapely (>=2.1.2,<3.0.0)",
"mcstatus (>=12.2.1,<13.0.0)",
"borax (>=4.1.3,<5.0.0)",
]
[tool.poetry]

View File

@ -5,7 +5,13 @@ def main():
with playwright.sync_api.sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://www.baidu.com")
content = page.content()
if "<html" in content or len(content) < 100:
print("✅ Playwright + Chromium 环境正常 (在 about:blank 页面上测试成功)")
else:
print("⚠️ Playwright + Chromium 环境启动,但页面内容检查结果异常。")
print(content)
raise Exception()
print("Playwright + Chromium 环境正常")
browser.close()

View File

@ -12,5 +12,8 @@ def filter(change: Change, path: str) -> bool:
return False
if Path(path).absolute().is_relative_to((base / ".git").absolute()):
return False
if Path(path).absolute().is_relative_to((base / "assets" / "oracle" / "image").absolute()):
# 还要解决坏枪的这个问题
return False
print(path)
return True