Compare commits

..

58 Commits

Author SHA1 Message Date
77cc2fef58 提醒我功能不使用思考模式;宾几人不再查询坏枪的两个服务器 2026-06-15 03:33:27 +08:00
379e677bea No drone anymore!!! I will deploy by myself!!! 2026-06-09 14:54:06 +08:00
1d89d80676 调整 AI 模型不要思考,以及添加 Wolfx 日志详细程度
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-06-09 14:49:37 +08:00
9265c250b3 补充一些权限系统有关的注释
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-20 19:34:12 +08:00
4dd9320678 取消罗文的反应机制
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-20 19:11:46 +08:00
db96202d5d 添加小睦想
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-19 00:11:32 +08:00
881b08c41f 此方晚安文档更新
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-13 15:33:40 +08:00
c11d29e136 Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot
Some checks failed
continuous-integration/drone/push Build is failing
2026-05-13 15:25:25 +08:00
1fa74b61d6 更新各种依赖 2026-05-13 15:25:11 +08:00
f0601acbe9 oyasumi
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-13 15:06:49 +08:00
39c7c043ca Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-29 18:52:01 +08:00
39accb16e0 桂花说 2026-04-29 18:51:37 +08:00
ec1f9627f3 Merge pull request 'chores: manual fix and pbr pipeline' (#73) from bkbkzzzz/konabot:marchtoy_gl into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #73
2026-04-28 14:11:47 +08:00
c0590dacbc Merge branch 'master' into marchtoy_gl 2026-04-28 14:11:08 +08:00
d748e242db manual fix 2026-04-28 14:09:45 +08:00
e2fd0809a5 PBR 2026-04-28 01:09:17 +08:00
2144b1e0eb 补充解压 Typst 构建产物需要的依赖
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-28 00:39:37 +08:00
7d0d53bead 修复并调整构建产物的生命周期
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-28 00:35:03 +08:00
4bfcc9b41c Merge pull request '修复偶发的数据库连接失效问题' (#72) from fix/database-lock into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #72
2026-04-28 00:12:44 +08:00
2b1a1c19d7 Merge remote-tracking branch 'origin/master' into fix/database-lock 2026-04-27 23:49:51 +08:00
cc486a6ac0 Merge pull request 'headless EGL backend' (#71) from bkbkzzzz/konabot:marchtoy_gl into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #71
Reviewed-by: 钟晓帕 <Passthem183@gmail.com>
2026-04-27 23:48:56 +08:00
4d4bbc86dc 在一个更统一的地方管理 connection 的 rollback 和丢弃 2026-04-27 23:33:53 +08:00
8f1e0b11a0 regex fix, camera default fix 2026-04-27 23:31:38 +08:00
7ba3035006 robustness + regex fix 2026-04-27 23:19:59 +08:00
0afbbd2fdf bug fixes 2026-04-27 23:08:25 +08:00
3175817b63 bool addition, few fixes 2026-04-27 22:49:19 +08:00
129870709b rad2deg 2026-04-27 21:56:05 +08:00
8997c430c9 manual 补充 2026-04-27 21:47:25 +08:00
250eaaf59c bool and smooth 2026-04-27 21:41:16 +08:00
733114b941 regex fix 2026-04-27 21:18:05 +08:00
cf52ea683b regex fix 2026-04-27 19:46:10 +08:00
d80d8d91c2 regex fix, more primitives 2026-04-27 18:11:32 +08:00
88f1f45b94 manual 补充; cpu 写法速度不可接受 2026-04-27 15:28:13 +08:00
b7f90b0c9e manual 补充,尝试迁移 cpu backend 2026-04-27 14:57:32 +08:00
6b152235cf Merge pull request '新增 marchtoy' (#70) from bkbkzzzz/konabot:marchtoy_gl into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #70
Reviewed-by: 钟晓帕 <Passthem183@gmail.com>
2026-04-27 02:42:16 +08:00
b4f167e5f6 regex fix 2026-04-27 02:07:01 +08:00
b720504e48 bug fixes 2026-04-27 01:26:42 +08:00
5d93af0666 补充 manual 2026-04-27 00:24:46 +08:00
24e59a7f52 more builtin colors 2026-04-27 00:06:55 +08:00
197535cd34 garbage collection 2026-04-27 00:02:45 +08:00
c3c22e7145 丰富了基本图形 2026-04-25 16:09:47 +08:00
6a68db70f5 fixed args 2026-04-25 15:45:41 +08:00
3f3a375dd6 column major 2026-04-25 14:58:50 +08:00
facd2d0e84 再见了,所有的skia 2026-04-25 14:41:43 +08:00
cc97ca5493 尝试 gl backend 2026-04-25 14:31:23 +08:00
81e0c05686 文件夹结构 2026-04-25 01:59:29 +08:00
3bf0735af4 marchtoy 初步 2026-04-25 01:13:32 +08:00
b73d020b67 不小心多commit了个png文件 2026-04-25 00:58:50 +08:00
0eb957ba87 写通了,存个档 2026-04-25 00:58:20 +08:00
2c1709a77d Raymarch 初步 2026-04-25 00:26:05 +08:00
87e5029be0 Merge pull request 'Enhancement/在应用层而非镜像构建层下载 Typst 构建产物' (#60) from enhancement/typst-binary into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #60
2026-04-23 21:50:15 +08:00
0ba51bc9b2 修复 rollback 失效问题
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-18 10:53:53 +08:00
27670920f6 vrsay
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-11 23:24:07 +08:00
157236d9a6 Merge branch 'master' into enhancement/typst-binary 2026-03-21 20:13:34 +08:00
8725f28caf 更改文件 2026-03-19 00:08:49 +08:00
fef9041a97 在本地开发环境保证 typst 二进制存在 2026-03-18 17:53:45 +08:00
6a2fe11753 不再直接在 Dockerfile 里面下载构建产物 2026-03-18 17:29:34 +08:00
97e87c7ec3 添加 Typst 的二进制文件下载 2026-03-18 17:26:36 +08:00
39 changed files with 3166 additions and 1593 deletions

View File

@ -1,105 +0,0 @@
---
kind: pipeline
name: 构建 Docker Nightly 镜像
type: docker
trigger:
event:
- push
branch:
- master
steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --init --recursive
- name: 构建 Docker 镜像
image: plugins/docker:latest
privileged: true
settings:
username: kagami-ci
password:
from_secret: KAGAMI-CI-PASSWORD
repo: gitea.service.jazzwhom.top/mttu-developers/konabot
registry: gitea.service.jazzwhom.top
tags:
- nightly
- nightly-${DRONE_COMMIT_SHA}
dockerfile: Dockerfile
volumes:
- name: docker-socket
path: /var/run/docker.sock
- name: 在容器中进行若干测试
image: docker:dind
privileged: true
volumes:
- name: docker-socket
path: /var/run/docker.sock
commands:
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python scripts/test_plugin_load.py
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python scripts/test_playwright.py
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python -m pytest --cov=./konabot/ --cov-report term-missing:skip-covered
- name: 发送构建结果到 ntfy
image: parrazam/drone-ntfy
when:
status: [success, failure]
settings:
url: https://ntfy.service.jazzwhom.top
topic: drone_ci
tags:
- drone-ci
token:
from_secret: NTFY_TOKEN
volumes:
- name: docker-socket
host:
path: /var/run/docker.sock
---
kind: pipeline
name: 构建 Docker Release 镜像
type: docker
trigger:
event:
- tag
steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --init --recursive
- name: 构建并推送 Release Docker 镜像
image: plugins/docker:latest
privileged: true
settings:
username: kagami-ci
password:
from_secret: KAGAMI-CI-PASSWORD
repo: gitea.service.jazzwhom.top/mttu-developers/konabot
registry: gitea.service.jazzwhom.top
tags:
- ${DRONE_TAG}
- latest
dockerfile: Dockerfile
volumes:
- name: docker-socket
path: /var/run/docker.sock
- name: 发送构建结果到 ntfy
image: parrazam/drone-ntfy
when:
status: [success, failure]
settings:
url: https://ntfy.service.jazzwhom.top
topic: drone_ci
tags:
- drone-ci
token:
from_secret: NTFY_TOKEN
volumes:
- name: docker-socket
host:
path: /var/run/docker.sock

3
.gitignore vendored
View File

@ -24,3 +24,6 @@ __pycache__
/.venv /.venv
/venv /venv
*.egg-info *.egg-info
# OS 相关
.DS_Store

View File

@ -1,16 +1,3 @@
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 FROM python:3.13-slim AS base
ENV VIRTUAL_ENV=/app/.venv \ ENV VIRTUAL_ENV=/app/.venv \
@ -18,6 +5,8 @@ ENV VIRTUAL_ENV=/app/.venv \
PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers
# 安装所有都需要的底层依赖 # 安装所有都需要的底层依赖
#
# xz-utils: 解压需要它
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libfontconfig1 libgl1 libegl1 libglvnd0 mesa-vulkan-drivers at-spi2-common fontconfig \ libfontconfig1 libgl1 libegl1 libglvnd0 mesa-vulkan-drivers at-spi2-common fontconfig \
@ -29,6 +18,7 @@ RUN apt-get update && \
libatk-bridge2.0-0t64 libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 \ libatk-bridge2.0-0t64 libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 \
libxkbcommon0 libasound2t64 libnss3 fonts-noto-cjk fonts-noto-cjk-extra \ libxkbcommon0 libasound2t64 libnss3 fonts-noto-cjk fonts-noto-cjk-extra \
fonts-noto-color-emoji \ fonts-noto-color-emoji \
xz-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@ -51,7 +41,6 @@ RUN uv sync --no-install-project
FROM base AS runtime FROM base AS runtime
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --from=artifacts /artifacts/ /usr/local/bin/
WORKDIR /app WORKDIR /app

BIN
assets/img/meme/vr.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/img/meme/xiaomu.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

View File

@ -4,7 +4,7 @@ Wolfx 防灾免费 API
import asyncio import asyncio
import json import json
from typing import Literal, TypeVar, cast from typing import TypeVar, cast
import aiohttp import aiohttp
from aiosignal import Signal from aiosignal import Signal
from loguru import logger from loguru import logger
@ -239,7 +239,8 @@ class WolfxAPIService:
logger.info(f"接收到来自 Wolfx API 的信息:{data}") logger.info(f"接收到来自 Wolfx API 的信息:{data}")
await signal.send(obj) await signal.send(obj)
except pydantic.ValidationError as e: except pydantic.ValidationError as e:
logger.warning(f"解析 Wolfx API 时出错 URL={ws.url}") data_text = data.decode('utf-8', 'replace')
logger.warning(f"解析 Wolfx API 时出错 URL={ws.url} raw={data_text}")
logger.error(e) logger.error(e)
async def start(self): # pragma: no cover async def start(self): # pragma: no cover

View File

@ -1,9 +1,10 @@
import asyncio import asyncio
from typing import Any, Awaitable, Callable
import aiohttp import aiohttp
import hashlib import hashlib
import platform import platform
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import nonebot import nonebot
@ -14,6 +15,8 @@ from pydantic import BaseModel
@dataclass @dataclass
class ArtifactDepends: class ArtifactDepends:
_Callback = Callable[[bool], Awaitable[Any]]
url: str url: str
sha256: str sha256: str
target: Path target: Path
@ -27,6 +30,9 @@ class ArtifactDepends:
use_proxy: bool = True use_proxy: bool = True
"网络问题,赫赫;使用的是 Discord 模块配置的 proxy" "网络问题,赫赫;使用的是 Discord 模块配置的 proxy"
callbacks: list[_Callback] = field(default_factory=list)
"在任务完成以后,应该做的事情"
def is_corresponding_platform(self) -> bool: def is_corresponding_platform(self) -> bool:
if self.required_os is not None: if self.required_os is not None:
if self.required_os.lower() != platform.system().lower(): if self.required_os.lower() != platform.system().lower():
@ -36,23 +42,42 @@ class ArtifactDepends:
return False return False
return True return True
def on_finished(self, task: _Callback) -> _Callback:
self.callbacks.append(task)
return task
async def _finished(self, downloaded: bool) -> list[Any | BaseException]:
tasks = set()
for f in self.callbacks:
tasks.add(f(downloaded))
result = await asyncio.gather(*tasks, return_exceptions=True)
for r in result:
if isinstance(r, BaseException):
logger.warning("完成了二进制文件的下载,但是有未捕捉的错误")
logger.exception(r)
return result
class Config(BaseModel): class Config(BaseModel):
prefetch_artifact: bool = False prefetch_artifact: bool = False
"是否提前下载好二进制依赖" "是否提前下载好二进制依赖"
artifact_list = [] artifact_list: list[ArtifactDepends] = []
driver = nonebot.get_driver() driver = nonebot.get_driver()
config = nonebot.get_plugin_config(Config) config = nonebot.get_plugin_config(Config)
@driver.on_startup @driver.on_startup
async def _(): async def _():
if config.prefetch_artifact: if config.prefetch_artifact:
logger.info("启动检测中:正在检测需求的二进制是否下载") logger.info("启动检测中:正在检测需求的二进制是否下载")
semaphore = asyncio.Semaphore(10) semaphore = asyncio.Semaphore(10)
async def _task(artifact: ArtifactDepends): async def _task(artifact: ArtifactDepends):
async with semaphore: async with semaphore:
await ensure_artifact(artifact) await ensure_artifact(artifact)
@ -78,35 +103,51 @@ async def download_artifact(artifact: ArtifactDepends):
async with aiohttp.ClientSession(proxy=proxy) as client: async with aiohttp.ClientSession(proxy=proxy) as client:
result = await client.get(artifact.url) result = await client.get(artifact.url)
if result.status != 200: if result.status != 200:
logger.warning(f"已经下载了二进制,但是注意服务器没有返回 200 URL={artifact.url} TARGET={artifact.target} CODE={result.status}") logger.warning(
f"已经下载了二进制,但是注意服务器没有返回 200 URL={artifact.url} TARGET={artifact.target} CODE={result.status}"
)
data = await result.read() data = await result.read()
artifact.target.write_bytes(data) artifact.target.write_bytes(data)
if not platform.system().lower() == 'windows': if not platform.system().lower() == "windows":
artifact.target.chmod(0o755) artifact.target.chmod(0o755)
logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}") logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}")
m = hashlib.sha256(artifact.target.read_bytes()) m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower(): if m.hexdigest().lower() != artifact.sha256.lower():
logger.warning(f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}") logger.warning(
f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}"
)
await artifact._finished(True)
async def ensure_artifact(artifact: ArtifactDepends): async def ensure_artifact(artifact: ArtifactDepends) -> bool:
"""
确保所需的二进制存在。返回是否下载了这个二进制文件。
"""
if not artifact.is_corresponding_platform(): if not artifact.is_corresponding_platform():
return logger.debug(f"所需求的平台不是当前平台,跳过二进制下载 artifact={artifact}")
return False
if not artifact.target.exists(): if not artifact.target.exists():
logger.info(f"二进制依赖 {artifact.target} 不存在") logger.info(f"二进制依赖 {artifact.target} 不存在")
if not artifact.target.parent.exists(): if not artifact.target.parent.exists():
artifact.target.parent.mkdir(parents=True, exist_ok=True) artifact.target.parent.mkdir(parents=True, exist_ok=True)
await download_artifact(artifact) await download_artifact(artifact)
return True
else: else:
m = hashlib.sha256(artifact.target.read_bytes()) m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower(): if m.hexdigest().lower() != artifact.sha256.lower():
logger.info(f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载") logger.info(
f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载"
)
artifact.target.unlink() artifact.target.unlink()
await download_artifact(artifact) await download_artifact(artifact)
return True
await artifact._finished(False)
return False
def register_artifacts(*artifacts: ArtifactDepends): def register_artifacts(*artifacts: ArtifactDepends):
artifact_list.extend(artifacts) artifact_list.extend(artifacts)

View File

@ -1,6 +1,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
import asyncio import asyncio
from loguru import logger
import sqlparse import sqlparse
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
@ -10,10 +11,20 @@ import aiosqlite
if TYPE_CHECKING: if TYPE_CHECKING:
from . import DatabaseManager from . import DatabaseManager
# 全局数据库管理器实例
_global_db_manager: Optional["DatabaseManager"] = None _global_db_manager: Optional["DatabaseManager"] = None
async def try_close_connection(conn: aiosqlite.Connection) -> bool:
try:
await conn.close()
return True
except Exception as e:
logger.error("有的连接关闭失败了")
logger.exception(e)
return False
def get_global_db_manager() -> "DatabaseManager": def get_global_db_manager() -> "DatabaseManager":
"""获取全局数据库管理器实例""" """获取全局数据库管理器实例"""
global _global_db_manager global _global_db_manager
@ -24,16 +35,10 @@ def get_global_db_manager() -> "DatabaseManager":
return _global_db_manager return _global_db_manager
def close_global_db_manager() -> None:
"""关闭全局数据库管理器实例"""
global _global_db_manager
if _global_db_manager is not None:
# 注意这个函数应该在async环境中调用close_all_connections
_global_db_manager = None
class DatabaseManager: class DatabaseManager:
"""异步数据库管理器""" """
异步数据库管理器
"""
def __init__(self, db_path: Optional[Union[str, Path]] = None, pool_size: int = 5): def __init__(self, db_path: Optional[Union[str, Path]] = None, pool_size: int = 5):
""" """
@ -56,6 +61,7 @@ class DatabaseManager:
async def _get_connection(self) -> aiosqlite.Connection: async def _get_connection(self) -> aiosqlite.Connection:
"""从连接池获取连接""" """从连接池获取连接"""
async with self._lock: async with self._lock:
# 尝试从池中获取现有连接 # 尝试从池中获取现有连接
while self._connection_pool: while self._connection_pool:
@ -67,10 +73,7 @@ class DatabaseManager:
return conn return conn
except: except:
# 连接已失效,关闭它 # 连接已失效,关闭它
try: await try_close_connection(conn)
await conn.close()
except:
pass
# 如果连接池为空,创建新连接 # 如果连接池为空,创建新连接
conn = await aiosqlite.connect(self.db_path) conn = await aiosqlite.connect(self.db_path)
@ -86,16 +89,31 @@ class DatabaseManager:
self._connection_pool.append(conn) self._connection_pool.append(conn)
else: else:
# 池已满,直接关闭连接 # 池已满,直接关闭连接
try: await try_close_connection(conn)
await conn.close()
except:
pass
@asynccontextmanager @asynccontextmanager
async def get_conn(self): async def get_conn(self):
"""
从 db 中获取一个 Connection
"""
conn = await self._get_connection() conn = await self._get_connection()
yield conn
await self._return_connection(conn) try:
yield conn
# 只有当一切正常时才归还数据库连接
await self._return_connection(conn)
except Exception as e:
logger.error("有模块使用一个连接时出现了错误")
logger.exception(e)
try:
await conn.rollback()
await conn.close()
except Exception as e:
logger.error("在 Rollback 和关闭时也出现了问题")
logger.exception(e)
async def query( async def query(
self, query: str, params: Optional[tuple] = None self, query: str, params: Optional[tuple] = None
@ -131,7 +149,7 @@ class DatabaseManager:
await conn.execute(command, params or ()) await conn.execute(command, params or ())
await conn.commit() await conn.commit()
except Exception as e: except Exception as e:
# 记录错误但重新抛出,让调用者处理 await conn.rollback()
raise Exception(f"数据库执行失败: {str(e)}") from e raise Exception(f"数据库执行失败: {str(e)}") from e
finally: finally:
await self._return_connection(conn) await self._return_connection(conn)
@ -143,7 +161,7 @@ class DatabaseManager:
await conn.executescript(script) await conn.executescript(script)
await conn.commit() await conn.commit()
except Exception as e: except Exception as e:
# 记录错误但重新抛出,让调用者处理 await conn.rollback()
raise Exception(f"数据库脚本执行失败: {str(e)}") from e raise Exception(f"数据库脚本执行失败: {str(e)}") from e
finally: finally:
await self._return_connection(conn) await self._return_connection(conn)
@ -190,42 +208,14 @@ class DatabaseManager:
else: else:
await self.execute_script(script) await self.execute_script(script)
async def execute_many(self, command: str, seq_of_params: List[tuple]) -> None:
"""执行多条非查询语句"""
conn = await self._get_connection()
try:
await conn.executemany(command, seq_of_params)
await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库批量执行失败: {str(e)}") from e
finally:
await self._return_connection(conn)
async def execute_many_values_by_sql_file(
self, file_path: Union[str, Path], seq_of_params: List[tuple]
) -> None:
"""从 SQL 文件中读取一条语句,但是被不同值同时执行"""
path = str(file_path) if isinstance(file_path, Path) else file_path
with open(path, "r", encoding="utf-8") as f:
command = f.read()
await self.execute_many(command, seq_of_params)
async def close_all_connections(self) -> None: async def close_all_connections(self) -> None:
"""关闭所有连接""" """关闭所有连接"""
async with self._lock: async with self._lock:
# 关闭池中的连接
for conn in self._connection_pool: for conn in self._connection_pool:
try: await try_close_connection(conn)
await conn.close()
except:
pass
self._connection_pool.clear() self._connection_pool.clear()
# 关闭正在使用的连接
for conn in self._in_use.copy(): for conn in self._in_use.copy():
try: await try_close_connection(conn)
await conn.close()
except:
pass
self._in_use.clear() self._in_use.clear()

View File

@ -29,10 +29,21 @@ async def _to_entity_chain(el: _EntityLike):
class PermManager: class PermManager:
"""
权限管理模块
"""
def __init__(self, db: DatabaseManager) -> None: def __init__(self, db: DatabaseManager) -> None:
self.db = db self.db = db
async def check_has_permission_info(self, entities: _EntityLike, key: str): async def get_permission_info(
self, entities: _EntityLike, key: str
) -> tuple[PermEntity, str, bool] | None:
"""
获得一个权限实体或权限实体串对一个 key 的权限信息。若未入库(默认值)则
代表没有该权限相关的记录
"""
entities = await _to_entity_chain(entities) entities = await _to_entity_chain(entities)
key = key.removesuffix("*").removesuffix(".") key = key.removesuffix("*").removesuffix(".")
key_split = key.split(".") key_split = key.split(".")
@ -52,17 +63,29 @@ class PermManager:
return None return None
async def check_has_permission(self, entities: _EntityLike, key: str) -> bool: async def check_has_permission(self, entities: _EntityLike, key: str) -> bool:
res = await self.check_has_permission_info(entities, key) """
检查一个权限实体或者权限实体串是否有权限
"""
res = await self.get_permission_info(entities, key)
if res is None: if res is None:
return False return False
return res[2] return res[2]
async def update_permission(self, entity: PermEntity, key: str, perm: bool | None): async def update_permission(self, entity: PermEntity, key: str, perm: bool | None):
"""
更新一个具体的权限实体的一则权限
"""
async with self.db.get_conn() as conn: async with self.db.get_conn() as conn:
repo = PermRepo(conn) repo = PermRepo(conn)
await repo.update_perm_info(entity, key, perm) await repo.update_perm_info(entity, key, perm)
async def list_permission(self, entities: _EntityLike, query: PagerQuery): async def list_permission(self, entities: _EntityLike, query: PagerQuery):
"""
列出一个权限实体或权限实体串拥有的所有权限记录
"""
entities = await _to_entity_chain(entities) entities = await _to_entity_chain(entities)
async with self.db.get_conn() as conn: async with self.db.get_conn() as conn:
repo = PermRepo(conn) repo = PermRepo(conn)
@ -113,6 +136,22 @@ def register_default_allow_permission(key: str):
def require_permission(perm: str) -> Rule: # pragma: no cover def require_permission(perm: str) -> Rule: # pragma: no cover
"""
`require_permission` 是一个 Nonebot 规则,可以用来要求一个 Nonebot 的指令需
要拥有一定的权限。
```python
from konabot.common.permsys import require_permission
from nonebot import on_command
cmd = on_command("kz", rule=require_permission("kagami.kz"))
@cmd.handle()
async def _():
await cmd.finish("你抓到了普通pt")
```
"""
async def check_permission(event: Event, pm: DepPermManager) -> bool: async def check_permission(event: Event, pm: DepPermManager) -> bool:
return await pm.check_has_permission(event, perm) return await pm.check_has_permission(event, perm)

View File

@ -22,6 +22,11 @@ class PermEntity:
def get_entity_chain_of_entity(entity: PermEntity) -> list[PermEntity]: def get_entity_chain_of_entity(entity: PermEntity) -> list[PermEntity]:
"""
获得一个权限实体的权限串。实际上返回三个权限,从小到大分别是用户、平台全体和
系统全局的权限实体。
"""
return [ return [
PermEntity("sys", "global", "global"), PermEntity("sys", "global", "global"),
PermEntity(entity.platform, "global", "global"), PermEntity(entity.platform, "global", "global"),
@ -30,6 +35,10 @@ def get_entity_chain_of_entity(entity: PermEntity) -> list[PermEntity]:
async def get_entity_chain(event: Event) -> list[PermEntity]: # pragma: no cover async def get_entity_chain(event: Event) -> list[PermEntity]: # pragma: no cover
"""
获得一个 Nonebot Event 的权限实体串。
"""
entities = [PermEntity("sys", "global", "global")] entities = [PermEntity("sys", "global", "global")]
if isinstance(event, OB11Event): if isinstance(event, OB11Event):

View File

@ -52,7 +52,11 @@ async def get_current_version(conn: aiosqlite.Connection) -> int:
if count[0] < 1: if count[0] < 1:
logger.info("权限系统数据表不存在,现在创建表") logger.info("权限系统数据表不存在,现在创建表")
await conn.executescript(SQL_CREATE_TABLE) await conn.executescript(SQL_CREATE_TABLE)
await conn.commit() try:
await conn.commit()
except Exception:
await conn.rollback()
raise
return 0 return 0
cursor = await conn.execute(SQL_GET_MIGRATE_VERSION) cursor = await conn.execute(SQL_GET_MIGRATE_VERSION)
row = await cursor.fetchone() row = await cursor.fetchone()
@ -72,10 +76,18 @@ async def execute_migration(
await conn.executescript(migration.get_upgrade_script()) await conn.executescript(migration.get_upgrade_script())
now_version += 1 now_version += 1
await conn.execute(SQL_UPDATE_VERSION, (now_version,)) await conn.execute(SQL_UPDATE_VERSION, (now_version,))
await conn.commit() try:
await conn.commit()
except Exception:
await conn.rollback()
raise
while now_version > version: while now_version > version:
migration = migrations[now_version - 1] migration = migrations[now_version - 1]
await conn.executescript(migration.get_downgrade_script()) await conn.executescript(migration.get_downgrade_script())
now_version -= 1 now_version -= 1
await conn.execute(SQL_UPDATE_VERSION, (now_version,)) await conn.execute(SQL_UPDATE_VERSION, (now_version,))
await conn.commit() try:
await conn.commit()
except Exception:
await conn.rollback()
raise

View File

@ -48,6 +48,7 @@ class PermRepo:
(entity.platform, entity.entity_type, entity.external_id), (entity.platform, entity.entity_type, entity.external_id),
) )
await self.conn.commit() await self.conn.commit()
eid = await self._get_entity_id_or_none(entity) eid = await self._get_entity_id_or_none(entity)
assert eid is not None assert eid is not None
return eid return eid

View File

@ -1,3 +0,0 @@
# 关于罗文和洛温
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。

View File

@ -0,0 +1,40 @@
# 指令介绍
简易 Raymarch 小玩具
用法march `[scene]`
march torus(1.0, 0.2).color(1.0, 0.2, 0.2) torus(1.0, 0.2).rot(90, 0, 0).color(0.2, 0.2, 1.0) camera(4.0).pos(4, 0, 0).lookat(0)
# 主要语法
`[scene]` ::= `[scene]` "." `[op]` |`[obj]`
`[obj]` ::= `[obj_ty]` | `[obj_ty]` "(" [args] ")"
`[op]` ::= `[op_ty]` | `[op_ty]` "(" `[args]` ")"
`[args]` ::= `[args]` "," `[arg]` | `[arg]`
其中 `obj_ty`、`op_ty` 分别为物体类型(如 `cube`、`sphere`、`torus` 等)与变换类型(如 `pos`、`rot`)。
# 支持的物体
目前支持的物体有(不包含 alias
`cube`:可选参数长宽高
`sphere`:可选参数半径
`torus`:可选参数半径与粗细
`cylinder`:可选参数半径与高度
`capsule`:可选参数高度与半径
特殊物体:
`mix`:混合两个物体
`bool`:两个物体相交
`minus`:两个物体相减
`camera`:相机,可选参数焦距
# 支持的变换
目前支持的变换有
`pos`:位移
`rot`:旋转(欧拉角 xyz
`color`:基础色
`lookat`:朝向
`rounded`:圆角
# 特殊说明
`[op_ty]` 不包含 scale。非正交的变换会破坏 SDF 的性质。

View File

@ -0,0 +1,8 @@
# 指令介绍
**此方晚安** - 让此方 BOT 禁言你一段时间
## 指令格式
- `@此方BOT 此方晚安`: 禁言几个小时
- `@此方BOT 此方午安`: 禁言几十分钟

View File

@ -0,0 +1,44 @@
import re
from typing import Any
from nonebot import on_message
from nonebot.adapters import Event
from nonebot_plugin_alconna import UniMessage, UniMsg
from playwright.async_api import Page
from konabot.common.nb import match_keyword
from konabot.common.web_render import WebRenderer, konaweb
async def render_image(message: str) -> UniMessage[Any]:
"""
渲染文本为图片
"""
async def page_function(page: Page):
await page.wait_for_function("typeof setContent === 'function'")
await page.evaluate(
"([ message ]) => { return setContent(message); }",
[ message ],
)
img_data = await WebRenderer.render(
url=konaweb("guihuasay"),
target="#main",
other_function=page_function,
)
return UniMessage.image(raw=img_data)
cmd = on_message(
rule=match_keyword.match_keyword(
re.compile(r"^(桂花[说想])\s.+", re.I),
),
)
@cmd.handle()
async def _(event: Event, msg: UniMsg):
text = msg.extract_plain_text().lstrip()
_, content = text.split(maxsplit=1)
msg = await render_image(content)
await msg.send(event)

View File

@ -23,7 +23,7 @@ class THQwen(TextHandler):
ostream="你或当前环境没有使用 qwen 的权限。如有疑问请联系管理员", ostream="你或当前环境没有使用 qwen 的权限。如有疑问请联系管理员",
) )
llm = get_llm() llm = get_llm(llm_model="qwen3.7-plus")
messages = [] messages = []
if istream is not None: if istream is not None:
@ -48,7 +48,9 @@ class THQwen(TextHandler):
"content": "除非用户要求,请尽可能短点回答。另外,当前环境不支持 Markdown 语法,如果可以,请使用纯文本回答", "content": "除非用户要求,请尽可能短点回答。另外,当前环境不支持 Markdown 语法,如果可以,请使用纯文本回答",
} }
] + messages ] + messages
result = await llm.chat(cast(Any, messages)) result = await llm.chat(
cast(Any, messages), extra_body={"enable_thinking": False}
)
content = result.content content = result.content
if content is None: if content is None:
return TextHandleResult( return TextHandleResult(

View File

@ -0,0 +1,22 @@
from nonebot import on_command
from nonebot.adapters import Message
from nonebot_plugin_alconna import UniMessage
from nonebot.params import CommandArg
import konabot.plugins.marchtoy.gl_render as render
# import konabot.plugins.marchtoy.cpu_render as render
import io
cmd_marchtoy = on_command("march")
@cmd_marchtoy.handle()
async def _(args: Message = CommandArg()):
if cmd := args.extract_plain_text():
try:
img = await render.render(cmd, (512, 512))
buffer = io.BytesIO()
# img.show()
# img.save("/mnt/d/output.png", format="GIF")
img.save(buffer, format="PNG")
buffer.seek(0)
await cmd_marchtoy.send(await UniMessage().image(raw=buffer).export())
except Exception as e:
await cmd_marchtoy.send(await UniMessage.text(f"cannot render: {e}").export())

View File

@ -0,0 +1,73 @@
"""
raymarch toy
usage: march <scene1> <scene2> ... <sceneN>
<scene> ::= <scene> "." <command>
| <command>
<command> ::= <id>
| <id> "(" <args> ")"
example:
march cube.pos(0, 0, 0).color(red) cam(1.0).pos(1, 1, 1).lookat(0, 0, 0)
"""
from dataclasses import dataclass
import regex as re
from dataclasses import dataclass
@dataclass
class Command:
id: str
args: list[str]
class CommandChainParser:
CHAIN_PATTERN = r"^(([a-zA-Z0-9\-+]+(?:\(([^()]*|(?1)+)(\s*\,\s*(?1))*\))?)(\.(?1))*)"
def __init__(self, _command_chain: str) -> None:
self.command_chain = _command_chain
def __iter__(self):
return self
def __next__(self):
if query := re.match(CommandChainParser.CHAIN_PATTERN, self.command_chain):
cmd_chain = query[0]
self.command_chain = self.command_chain[len(cmd_chain) + 1 :]
return cmd_chain
raise StopIteration
class CommandParser:
CMD_PATTERN = r"^[a-zA-Z]+(?:\(([0-9.\-+]+|(([a-zA-Z0-9]+(?:\(([^()]*|(?1)+)(\s*\,\s*(?1))*\))?)(\.(?1))*))(\s*\,\s*(?1))*\))?"
ID_PATTERN = r"^[a-zA-Z]+(?=\(|\.|$)"
ARG_PATTERN = CommandChainParser.CHAIN_PATTERN
TRIM_PATTERN = r"^\s*\,\s*"
def __init__(self, _command: str) -> None:
self.command = _command
def __iter__(self):
return self
def __next__(self):
if query := re.match(CommandParser.CMD_PATTERN, self.command):
cmd = query[0]
if cmd_id_qry := re.match(CommandParser.ID_PATTERN, cmd):
id = cmd_id_qry[0]
cmd_args = cmd[len(id) + 1 : -1] # .replace(" ", "").split(",")
args: list[str] = []
self.command = self.command[len(cmd) + 1 :]
while cmd_arg_qry := re.match(CommandParser.ARG_PATTERN, cmd_args):
arg = cmd_arg_qry[0]
args.append(arg)
cmd_args = cmd_args[len(arg) :]
if trim_qry := re.match(CommandParser.TRIM_PATTERN, cmd_args):
cmd_args = cmd_args[len(trim_qry[0]) :]
# while "" in cmd_args:
# cmd_args.remove("")
return Command(id, args)
raise StopIteration

View File

@ -0,0 +1,44 @@
"""
headless moderngl
"""
import pathlib
import moderngl
import numpy as np
from PIL import Image
from konabot.plugins.marchtoy.scene import Scene
from nonebot import logger
PATH = pathlib.Path(__file__).parent / "shaders"
with (PATH / "vert.glsl").open(encoding='utf-8') as f:
VS_SRC = f.read()
async def render(command: str, res: tuple[int, int]):
fs = Scene(command).compile()
try:
ctx = moderngl.create_context(standalone=True)
except:
ctx = moderngl.create_context(standalone=True, backend="egl")
ctx.gc_mode = "auto"
try:
program = ctx.program(
vertex_shader=VS_SRC,
fragment_shader=fs
)
except Exception as e:
raise Exception(f"cannot compile glsl: {e}") from e
try:
uniform = program["u_resolution"]
except Exception as e:
raise Exception("无法获取 uniform可能相机位于物体内部")
uniform.write(np.array(res, dtype=np.float32))
vertices = np.array([-1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0], dtype=np.float32)
indices = np.array([0, 1, 2, 1, 2, 3], dtype=np.int32)
ibo = ctx.buffer(indices)
vbo = ctx.buffer(vertices)
vao = ctx.vertex_array(program, vbo, 'in_position', index_buffer = ibo)
fbo = ctx.simple_framebuffer(res)
fbo.use()
fbo.clear(0.0, 0.0, 0.0, 0.0)
vao.render(moderngl.TRIANGLES)
return Image.frombytes('RGBA', fbo.size, fbo.read(components=4), 'raw', 'RGBA', 0, -1)

View File

@ -0,0 +1,278 @@
import numpy as np
from konabot.plugins.marchtoy.texture import Texture
from konabot.plugins.marchtoy.utilities import ArgParser, Formatter
from nonebot import logger
from typing import Optional
import re
OBJECT_ENTRIES = {}
def make_obj(*name: str):
def decorator(cls):
# OBJECT_ENTRIES[name] = cls
for alias in [*name]:
OBJECT_ENTRIES[alias] = cls
return cls
return decorator
class Transform:
def __init__(self) -> None:
self.t: np.ndarray = np.identity(4, dtype=np.float32)
@staticmethod
def normalize(p: np.ndarray) -> np.ndarray:
return p / (np.linalg.norm(p) + 1e-8)
def translate(self, x: float, y: float, z: float):
mat = np.identity(4, dtype=np.float32)
mat[0:3, 3] = [x, y, z]
self.t = mat @ self.t
return self
# scale 会破坏 sdf 的性质 梯度大小会变 导致 overshoot 等问题
def scale(self, x: float, y: float, z: float):
mat = np.identity(4, dtype=np.float32)
mat[0, 0], mat[1, 1], mat[2, 2] = x, y, z
self.t = mat @ self.t
return self
def rotate(self, x: float, y: float, z: float):
x = x / 180 * np.pi
y = y / 180 * np.pi
z = z / 180 * np.pi
cx, sx = np.cos(x), np.sin(x)
cy, sy = np.cos(y), np.sin(y)
cz, sz = np.cos(z), np.sin(z)
mat = np.identity(4, dtype=np.float32)
mat[0, 0] = cy * cz
mat[0, 1] = sx * sy * cz - cx * sz
mat[0, 2] = cx * sy * cz + sx * sz
mat[1, 0] = cy * sz
mat[1, 1] = sx * sy * sz + cx * cz
mat[1, 2] = cx * sy * sz - sx * cz
mat[2, 0] = -sy
mat[2, 1] = sx * cy
mat[2, 2] = cx * cy
self.t = mat @ self.t
return self
def lookat(
self, x: float, y: float, z: float, up: np.ndarray = np.array([0.0, 1.0, 0.0])
):
p = self.t[0:3, 3]
q = np.array([x, y, z])
zaxis = Transform.normalize(q - p)
xaxis = Transform.normalize(np.cross(zaxis, up))
yaxis = Transform.normalize(np.cross(xaxis, zaxis))
self.t[:3, 0] = xaxis
self.t[:3, 1] = yaxis
self.t[:3, 2] = -zaxis
return self
def p_expr(self) -> str:
inv = np.linalg.inv(self.t) # + 1e-5 * np.identity(4, dtype=np.float32))
return f"({Formatter.to_vec4(inv)} * vec4(p, 1.0)).xyz"
class Object:
def __init__(self) -> None:
self.transform: Transform = Transform()
self.texture: Texture = Texture()
self.round_corner: float = 0.0
def parse_args(self, args: list[str]):
raise NotImplementedError
def get_transformed(self, p: np.ndarray) -> np.ndarray:
if p.shape != (3,):
raise Exception(f"{p} is not a vec3")
p = p.copy()
inv = np.linalg.inv(self.transform.t)
return (inv @ np.array((*p, 1)))[:3]
def sdf_block_glsl(self) -> str:
raise NotImplementedError
@make_obj("cube", "box")
class Cube(Object):
def __init__(self, _size: np.ndarray = np.array([1.0, 1.0, 1.0])) -> None:
super().__init__()
self.size = _size
def parse_args(self, args: str):
self.size = ArgParser.as_vec3(args)
def sdf_block_glsl(self) -> str:
return f"sdCube({self.transform.p_expr()}, vec3({self.size[0]}, {self.size[1]}, {self.size[2]}))"
@make_obj("sphere", "ball")
class Sphere(Object):
def __init__(self, _radius: float = 1.0) -> None:
super().__init__()
self.radius = _radius
def parse_args(self, args: str):
self.radius = ArgParser.as_float(args)
def sdf_block_glsl(self) -> str:
return f"sdSphere({self.transform.p_expr()}, {self.radius})"
@make_obj("cylinder", "cyl")
class Cylinder(Object):
def __init__(self, _radius: float = 1.0, _height: float = 1.0) -> None:
super().__init__()
self.radius = _radius
self.height = _height
def parse_args(self, args: str):
param = ArgParser.as_vec2(args)
self.radius = param[0]
self.height = param[1]
def sdf_block_glsl(self) -> str:
return (
f"sdCappedCylinder({self.transform.p_expr()}, {self.radius}, {self.height})"
)
@make_obj("torus")
class Torus(Object):
def __init__(self, _r1: float = 1.0, _r2: float = 0.4) -> None:
super().__init__()
self.r1 = _r1
self.r2 = _r2
def parse_args(self, args: list[str]):
param = ArgParser.as_vec2(args)
self.r1 = param[0]
self.r2 = param[1]
def sdf_block_glsl(self) -> str:
return f"sdTorus({self.transform.p_expr()}, vec2({self.r1}, {self.r2}))"
@make_obj("capsule", "pill")
class Capsule(Object):
def __init__(self, _h: float = 1.0, _r: float = 0.25) -> None:
super().__init__()
self.h = _h
self.r = _r
def parse_args(self, args: list[str]):
param = ArgParser.as_vec2(args)
self.h = param[0]
self.r = param[1]
def sdf_block_glsl(self) -> str:
return f"sdVerticalCapsule({self.transform.p_expr()}, {self.h}, {self.r})"
@make_obj("smoothed", "mix")
class Smoothed(Object):
def __init__(self, _k: float = 12.00):
super().__init__()
self.block_a: str = "INF"
self.block_b: str = "INF"
self.k: float = _k
def parse_args(self, args: list[str]):
from konabot.plugins.marchtoy.scene import Scene
try:
if not len(args) >= 2:
raise Exception("expecting at least 2 args")
scene_a = Scene(args[0])
scene_b = Scene(args[1])
self.block_a = scene_a.canvas_objs[0][1]
self.block_b = scene_b.canvas_objs[0][1]
if len(args) > 2:
self.k = ArgParser.as_float(args[2], 0.25)
except Exception as e:
raise Exception(f"cannot build smoothed object over {args}: {e}")
def sdf_block_glsl(self):
return f"smin({self.block_a}, {self.block_b}, {self.k})"
@make_obj("intersect", "bool")
class BoolIntersect(Object):
def __init__(self):
super().__init__()
def parse_args(self, args: list[str]):
from konabot.plugins.marchtoy.scene import Scene
try:
if not len(args) >= 2:
raise Exception("expecting at least 2 args")
scene_a = Scene(args[0])
scene_b = Scene(args[1])
self.block_a = scene_a.canvas_objs[0][1]
self.block_b = scene_b.canvas_objs[0][1]
if len(args) > 2:
self.k = ArgParser.as_float(args[2], 0.25)
except Exception as e:
raise Exception(f"cannot build bool object over {args}: {e}")
def sdf_block_glsl(self):
return f"max({self.block_a}, {self.block_b})"
@make_obj("substract", "minus")
class BoolSubstract(Object):
def __init__(self):
super().__init__()
def parse_args(self, args: list[str]):
from konabot.plugins.marchtoy.scene import Scene
try:
if not len(args) >= 2:
raise Exception("expecting at least 2 args")
scene_a = Scene(args[0])
scene_b = Scene(args[1])
self.block_a = scene_a.canvas_objs[0][1]
self.block_b = scene_b.canvas_objs[0][1]
if len(args) > 2:
self.k = ArgParser.as_float(args[2], 0.25)
except Exception as e:
raise Exception(f"cannot build bool object over {args}: {e}")
def sdf_block_glsl(self):
return f"max({self.block_a}, -{self.block_b})"
@make_obj("add", "addition")
class BoolAddition(Object):
def __init__(self):
super().__init__()
def parse_args(self, args: list[str]):
from konabot.plugins.marchtoy.scene import Scene
try:
if not len(args) >= 2:
raise Exception("expecting at least 2 args")
scene_a = Scene(args[0])
scene_b = Scene(args[1])
self.block_a = scene_a.canvas_objs[0][1]
self.block_b = scene_b.canvas_objs[0][1]
except Exception as e:
raise Exception(f"cannot build bool object over {args}: {e}")
def sdf_block_glsl(self):
return f"min({self.block_a}, {self.block_b})"
@make_obj("camera", "cam")
class Camera(Object):
def __init__(self, _focus: float = 2.0) -> None:
super().__init__()
self.focus = _focus
# self.transform.translate(8.0, 8.0, 8.0).lookat(0.0, 0.0, 0.0)
def parse_args(self, args: list[str]):
self.focus = ArgParser.as_float(args)

View File

@ -0,0 +1,56 @@
from konabot.plugins.marchtoy.obj import Object
from konabot.plugins.marchtoy.utilities import ArgParser
OPERATION_ENTRIES = {}
def make_op(*name: str):
def decorator(op):
# OPERATION_ENTRIES[name] = op
for alias in [*name]:
OPERATION_ENTRIES[alias] = op
return op
return decorator
@make_op("pos", "translate", "position", "p")
def translate(obj: Object, args: list[str]):
pos = ArgParser.as_vec3(args)
obj.transform.translate(pos[0], pos[1], pos[2])
@make_op("rot", "rotate", "r")
def rotate(obj: Object, args: list[str]):
pos = ArgParser.as_vec3(args)
obj.transform.rotate(pos[0], pos[1], pos[2])
@make_op("lookat", "look", "l")
def lookat(obj: Object, args: list[str]):
pos = ArgParser.as_vec3(args)
obj.transform.lookat(pos[0], pos[1], pos[2])
@make_op("color", "col", "texture")
def color(obj: Object, args: list[str]):
try:
if len(args) == 1:
col = ArgParser.as_literal_color(args)
obj.texture.color = (col[0], col[1], col[2], col[3])
elif len(args) == 3:
col = ArgParser.as_vec3(args)
obj.texture.color = (col[0], col[1], col[2], 1.0)
elif len(args) == 4:
col = ArgParser.as_vec4(args)
obj.texture.color = (col[0], col[1], col[2], col[3])
else:
raise Exception("invalid argument number")
except:
raise Exception("unknown color")
@make_op("rounded", "round_corner", "corner")
def rounded(obj: Object, args: list[str]):
if len(args) >= 1:
obj.round_corner = ArgParser.as_float(args)

View File

@ -0,0 +1,100 @@
from typing import Optional
import pathlib
import numpy as np
from nonebot import logger
PATH = pathlib.Path(__file__).parent / "shaders" / "frag.glsl"
with PATH.open(encoding="utf-8") as f:
FS_SRC = f.read()
class Scene:
def __init__(self, _instruction: str) -> None:
from konabot.plugins.marchtoy.command import CommandChainParser, CommandParser
from konabot.plugins.marchtoy.op import OPERATION_ENTRIES
from konabot.plugins.marchtoy.obj import Object, Camera, OBJECT_ENTRIES
logger.info(f"building scene: {_instruction}")
self.canvas_objs: list[tuple[Object, str]] = []
self.camera: Camera = Camera(1.0 / np.tan(np.deg2rad(30.0)))
self.camera.transform.translate(8.0, 8.0, 8.0).lookat(0.0, 0.0, 0.0)
for raw_cmd in CommandChainParser(_instruction):
cmd_queue = CommandParser(raw_cmd)
cmd_obj = next(cmd_queue)
obj_id, obj_args = cmd_obj.id, cmd_obj.args
logger.info(f"parsing object: {obj_id} with args {obj_args}")
obj_instance: Optional[Object] = None
if obj_id in OBJECT_ENTRIES:
obj_cls = OBJECT_ENTRIES[obj_id]
if not issubclass(obj_cls, Object):
raise Exception(f"{obj_id} is not a subclass of Object.")
obj_instance = obj_cls()
try:
if len(obj_args) != 0:
obj_instance.parse_args(obj_args)
except Exception as e:
raise Exception(
f"object {obj_id} failed to parse args passed in: {obj_args}.\n{e}"
) from e
else:
raise Exception(f"{obj_id} is not a valid object type.")
logger.info(f"parsed object {obj_id}({obj_args})")
if obj_instance != None:
for cmd in cmd_queue:
op_id, op_args = cmd.id, cmd.args
logger.info(f"parsing operation {op_id} with args {op_args}")
if op_id in OPERATION_ENTRIES:
op_func = OPERATION_ENTRIES[op_id]
if not callable(op_func):
raise Exception(f"{op_id} is not a valid operation.")
op_func(obj_instance, op_args)
else:
raise Exception(f"{op_id} is not a valid operation.")
logger.info(f"parsed operation {op_id}({op_args})")
try:
sdf_block = obj_instance.sdf_block_glsl()
self.canvas_objs.append((obj_instance, sdf_block))
logger.info(f"parsed sdf {sdf_block}")
except:
# logger.info(f"parsed camera")
if type(obj_instance) == Camera:
self.camera = obj_instance
def __str__(self) -> str:
return ", ".join([str(type(obj)) for obj in self.canvas_objs])
def compile(self) -> str:
sdf_block: str = ""
color_block: str = ""
index = 0
for canvas_item in self.canvas_objs:
obj, sdf_expr = canvas_item
round_corner = f"- {obj.round_corner}" if obj.round_corner > 1e-8 else ""
logger.info(round_corner)
sdf_block += f"float sd{index} = {sdf_expr}{round_corner};"
sdf_block += f"if(sd{index} < qry.value)"
sdf_block += "{" + f"qry.value = sd{index}; qry.obj_id = {index}; " + "}\n"
color = obj.texture.color
color_block += (
f"if(obj_id == {index}) return vec4("
f"{color[0]}, {color[1]}, {color[2]}, {color[3]});\n"
)
index += 1
content = FS_SRC
content = content.replace("<SDF_BLOCK>", sdf_block)
content = content.replace("<COLOR_BLOCK>", color_block)
cam_pos = self.camera.transform.t[0:3, 3]
cam_focus = self.camera.focus
cam_dir = self.camera.transform.t @ np.array((0.0, 0.0, -1.0, 0.0))
content = content.replace(
"<CAM_POS>", f"{cam_pos[0]}, {cam_pos[1]}, {cam_pos[2]}"
)
content = content.replace("<CAM_FOCUS>", str(cam_focus))
content = content.replace(
"<CAM_DIR>", f"{cam_dir[0]}, {cam_dir[1]}, {cam_dir[2]}"
)
return content

View File

@ -0,0 +1,168 @@
#version 330
const float EPS = 0.001;
const int MAX_ITER = 128;
const float INF = 1e10;
const float PI = 3.14159;
uniform vec2 u_resolution;
out vec4 fragColor;
struct sdQuery {
float value;
int obj_id;
};
float sdCube(vec3 p, vec3 b) {
p = abs(p) - b;
return length(max(p, 0.0)) + min(max(p.x, max(p.y, p.z)), 0.0);
}
float sdSphere(vec3 p, float r) {
return length(p) - r;
}
float sdCappedCylinder( vec3 p, float r, float h )
{
vec2 d = abs(vec2(length(p.xz),p.y)) - vec2(r,h);
return min(max(d.x,d.y),0.0) + length(max(d,0.0));
}
float sdVerticalCapsule( vec3 p, float h, float r )
{
p.y -= clamp( p.y, 0.0, h );
return length( p ) - r;
}
float sdTorus( vec3 p, vec2 t )
{
vec2 q = vec2(length(p.xz)-t.x,p.y);
return length(q)-t.y;
}
// float smin( float a, float b, float k )
// {
// k *= 1.0;
// float r = exp2(-a/k) + exp2(-b/k);
// return -k*log2(r);
// }
float smin( float a, float b, float k )
{
k *= 2.0;
float x = (b-a)/k;
float g = 0.5*(x+sqrt(x*x+1.0));
return b - k * g;
}
sdQuery sd(vec3 p) {
sdQuery qry;
qry.value = INF;
qry.obj_id = -1;
<SDF_BLOCK>
return qry;
}
vec3 nrm(vec3 p) {
vec2 d = vec2(EPS, 0.0);
return normalize(vec3(
sd(p + d.xyy).value - sd(p - d.xyy).value,
sd(p + d.yxy).value - sd(p - d.yxy).value,
sd(p + d.yyx).value - sd(p - d.yyx).value
));
}
float saturate(float x) {
return clamp(x, 0.0, 1.0);
}
float ggx_distribution(vec3 n, vec3 h, float roughness) {
float alpha = roughness * roughness;
float alpha2 = alpha * alpha;
float NdotH = saturate(dot(n, h));
float denom = NdotH * NdotH * (alpha2 - 1.0) + 1.0;
return alpha2 / max(PI * denom * denom, EPS);
}
float geometry_schlick_ggx(float NdotX, float roughness) {
float r = roughness + 1.0;
float k = r * r / 8.0;
return NdotX / max(NdotX * (1.0 - k) + k, EPS);
}
float geometry_smith(vec3 n, vec3 v, vec3 l, float roughness) {
float NdotV = saturate(dot(n, v));
float NdotL = saturate(dot(n, l));
return geometry_schlick_ggx(NdotV, roughness) * geometry_schlick_ggx(NdotL, roughness);
}
vec3 fresnel_schlick(vec3 f0, float cos_theta) {
return f0 + (1.0 - f0) * pow(1.0 - saturate(cos_theta), 5.0);
}
vec3 tonemap_aces(vec3 c) {
const float a = 2.51;
const float b = 0.03;
const float c1 = 2.43;
const float d = 0.59;
const float e = 0.14;
return clamp((c * (a * c + b)) / (c * (c1 * c + d) + e), 0.0, 1.0);
}
vec4 materialColor(int obj_id) {
<COLOR_BLOCK>
return vec4(1.0);
}
vec4 color(vec3 p, vec3 r, int obj_id) {
vec3 light_col = vec3(1.0);
vec4 albedo = materialColor(obj_id);
vec3 N = nrm(p);
vec3 V = normalize(-r);
vec3 L = normalize(vec3(0.5, 0.8, -0.6));
vec3 H = normalize(V + L);
float roughness = 0.45;
float metallic = 0.02;
float NdotL = saturate(dot(N, L));
float NdotV = saturate(dot(N, V));
float D = ggx_distribution(N, H, roughness);
float G = geometry_smith(N, V, L, roughness);
vec3 F0 = mix(vec3(0.04), albedo.rgb, metallic);
vec3 F = fresnel_schlick(F0, dot(V, H));
vec3 kD = (1.0 - F) * (1.0 - metallic);
vec3 diffuse = kD * albedo.rgb / PI;
vec3 specular = D * G * F / max(4.0 * NdotL * NdotV, EPS);
float hemi = N.y * 0.5 + 0.5;
vec3 sky_ambient = vec3(0.60, 0.72, 0.92);
vec3 ground_ambient = vec3(0.18, 0.16, 0.14);
vec3 ambient = mix(ground_ambient, sky_ambient, hemi) * (diffuse + 0.25 * F0) * 0.35;
vec3 col = ambient + (diffuse + specular) * light_col * NdotL;
col = tonemap_aces(col);
col = pow(col, vec3(1.0 / 2.2));
return vec4(col, 1.0);
}
vec4 march(vec3 p, vec3 r) {
sdQuery qry;
vec4 col = vec4(0.0);
for(int i = 0; i < MAX_ITER; ++i) {
qry = sd(p);
if(qry.value < EPS){
col = color(p, r, qry.obj_id);
break;
}
p += qry.value * r;
}
return col;
}
void main() {
vec2 uv = 2. * (gl_FragCoord.xy / u_resolution - .5) * vec2(u_resolution.x / u_resolution.y, 1.);
vec3 c_p = vec3(<CAM_POS>);
vec3 c_z = normalize(vec3(<CAM_DIR>));
vec3 world_up = abs(c_z.y) > 0.999 ? vec3(0., 0., 1.) : vec3(0., 1., 0.);
vec3 c_x = normalize(cross(c_z, world_up));
vec3 c_y = normalize(cross(c_x, c_z));
mat3 view = mat3(c_x, c_y, c_z);
vec3 r = normalize(vec3(uv, <CAM_FOCUS>));
fragColor = march(c_p, view * r);
}

View File

@ -0,0 +1,6 @@
#version 330
in vec2 in_position;
void main() {
gl_Position = vec4(in_position, 0.0, 1.0);
}

View File

@ -0,0 +1,54 @@
from dataclasses import dataclass
COLORS = {
"red": (1.0, 0.0, 0.0, 1.0),
"green": (0.0, 1.0, 0.0, 1.0),
"blue": (0.0, 0.0, 1.0, 1.0),
"white": (1.0, 1.0, 1.0, 1.0),
"black": (0.0, 0.0, 0.0, 1.0),
"gray": (0.5, 0.5, 0.5, 1.0),
"light_gray": (0.75, 0.75, 0.75, 1.0),
"dark_gray": (0.25, 0.25, 0.25, 1.0),
"dark_red": (0.5, 0.0, 0.0, 1.0),
"crimson": (0.86, 0.08, 0.24, 1.0),
"pink": (1.0, 0.75, 0.8, 1.0),
"hot_pink": (1.0, 0.41, 0.71, 1.0),
"orange_red": (1.0, 0.27, 0.0, 1.0),
"orange": (1.0, 0.65, 0.0, 1.0),
"gold": (1.0, 0.84, 0.0, 1.0),
"yellow": (1.0, 1.0, 0.0, 1.0),
"light_yellow": (1.0, 1.0, 0.88, 1.0),
"khaki": (0.94, 0.90, 0.55, 1.0),
"light_green": (0.56, 0.93, 0.56, 1.0),
"lime": (0.0, 1.0, 0.0, 1.0),
"forest_green": (0.13, 0.55, 0.13, 1.0),
"dark_green": (0.0, 0.39, 0.0, 1.0),
"olive": (0.5, 0.5, 0.0, 1.0),
"teal": (0.0, 0.5, 0.5, 1.0),
"light_blue": (0.68, 0.85, 0.9, 1.0),
"sky_blue": (0.53, 0.81, 0.92, 1.0),
"cyan": (0.0, 1.0, 1.0, 1.0),
"navy": (0.0, 0.0, 0.5, 1.0),
"royal_blue": (0.25, 0.41, 0.88, 1.0),
"steel_blue": (0.27, 0.51, 0.71, 1.0),
"purple": (0.5, 0.0, 0.5, 1.0),
"magenta": (1.0, 0.0, 1.0, 1.0),
"violet": (0.93, 0.51, 0.93, 1.0),
"lavender": (0.90, 0.90, 0.98, 1.0),
"indigo": (0.29, 0.0, 0.51, 1.0),
"brown": (0.65, 0.16, 0.16, 1.0),
"saddle_brown": (0.55, 0.27, 0.07, 1.0),
"chocolate": (0.82, 0.41, 0.12, 1.0),
"tan": (0.82, 0.71, 0.55, 1.0),
"beige": (0.96, 0.96, 0.86, 1.0),
"coral": (1.0, 0.5, 0.31, 1.0),
"salmon": (0.98, 0.5, 0.45, 1.0),
"turquoise": (0.25, 0.88, 0.82, 1.0),
"aqua": (0.0, 1.0, 1.0, 1.0),
"plum": (0.87, 0.63, 0.87, 1.0),
"wheat": (0.96, 0.87, 0.70, 1.0),
}
@dataclass
class Texture:
color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)

View File

@ -0,0 +1,99 @@
import numpy as np
import regex as re
from konabot.plugins.marchtoy.texture import COLORS
class Formatter:
@staticmethod
def to_vec4(m: np.ndarray) -> str:
if m.shape != (4, 4):
m = np.identity(4)
v_0 = ", ".join([str(x) for x in m[:, 0]])
v_1 = ", ".join([str(x) for x in m[:, 1]])
v_2 = ", ".join([str(x) for x in m[:, 2]])
v_3 = ", ".join([str(x) for x in m[:, 3]])
return f"mat4(vec4({v_0}), vec4({v_1}), vec4({v_2}), vec4({v_3}))"
"""
TODO: 除零出现 nan 情况的单独处理
"""
class ArgParser:
@staticmethod
def to_params(args: str, delim: str = ',') -> list[str]:
raise DeprecationWarning
_params = args.replace(" ", "").split(delim)
params: list[str] = []
# 还是避免 while 为好
for param in _params:
if param != "":
params.append(param)
return params
@staticmethod
def as_float(args: list[str] | str, default: float = 0.0) -> float:
try:
if isinstance(args, list) and len(args) >= 1:
x = float(args[0])
return x
elif isinstance(args, str):
x = float(args)
return x
except:
# raise Exception(f"cannot parse {args}")
return default
@staticmethod
def as_vec2(
args: list[str], default: np.ndarray = np.array((0.0, 0.0))
) -> np.ndarray:
try:
if len(args) == 1:
x = float(args[0])
return np.array((x, x))
elif len(args) == 2:
x = float(args[0])
y = float(args[1])
return np.array((x, y))
except:
raise Exception(f"cannot parse {args}")
return default
@staticmethod
def as_vec3(
args: list[str], default: np.ndarray = np.array((0.0, 0.0, 0.0))
) -> np.ndarray:
try:
if len(args) == 1:
x = float(args[0])
return np.array((x, x, x))
elif len(args) == 3:
x = float(args[0])
y = float(args[1])
z = float(args[2])
return np.array((x, y, z))
return default
except:
raise Exception(f"cannot parse {args}")
@staticmethod
def as_vec4(
args: list[str], default: np.ndarray = np.array((0.0, 0.0, 0.0, 0.0))
) -> np.ndarray:
try:
if len(args) == 1:
x = float(args[0])
return np.array((x, x, x, x))
elif len(args) == 4:
x = float(args[0])
y = float(args[1])
z = float(args[2])
w = float(args[3])
return np.array((x, y, z, w))
except:
raise Exception(f"cannot parse {args}")
return default
@staticmethod
def as_literal_color(args: list[str], default=np.array((1.0, 1.0, 1.0, 1.0))):
if len(args) == 1 and args[0] in COLORS:
return np.array(COLORS[args[0]])
return default

View File

@ -1,6 +1,8 @@
from io import BytesIO from io import BytesIO
from typing import Iterable, cast from typing import Iterable, cast
import PIL.Image
from loguru import logger from loguru import logger
from nonebot import on_message from nonebot import on_message
from nonebot_plugin_alconna import ( from nonebot_plugin_alconna import (
@ -18,7 +20,7 @@ from nonebot_plugin_alconna import (
from playwright.async_api import ConsoleMessage, Page from playwright.async_api import ConsoleMessage, Page
from konabot.common.nb.match_keyword import match_keyword from konabot.common.nb.match_keyword import match_keyword
from konabot.common.nb.extract_image import DepPILImage from konabot.common.nb.extract_image import DepImageBytesOrNone, DepPILImage
from konabot.common.web_render import konaweb from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer from konabot.common.web_render.core import WebRenderer
from konabot.common.web_render.host_images import host_tempdir from konabot.common.web_render.host_images import host_tempdir
@ -34,6 +36,8 @@ from konabot.plugins.memepack.drawing.saying import (
draw_mnk, draw_mnk,
draw_pt, draw_pt,
draw_suan, draw_suan,
draw_vr,
draw_xm
) )
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
@ -334,3 +338,59 @@ async def _(img: DepPILImage):
result_bytes = BytesIO() result_bytes = BytesIO()
result.save(result_bytes, format="PNG") result.save(result_bytes, format="PNG")
await doubao_cmd.send(await UniMessage().image(raw=result_bytes).export()) await doubao_cmd.send(await UniMessage().image(raw=result_bytes).export())
vrsay = on_alconna(
Alconna(
"vr说",
Args[
"saying",
MultiVar(str, "+"),
Field(missing_tips=lambda: "你没有写vr说了什么"),
],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases=set(),
)
@vrsay.handle()
async def _(saying: list[str]):
img = await draw_vr("\n".join(saying))
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
await vrsay.send(await UniMessage().image(raw=img_bytes).export())
xmsay = on_alconna(
Alconna(
"小睦说",
Args[
"saying",
MultiVar(str, "*"),
Field(missing_tips=lambda: "你没有写小睦说了什么"),
],
Args["image?", Image | None],
),
use_cmd_start=True,
use_cmd_sep=False,
skip_for_unmatch=False,
aliases={"小睦想"},
)
@xmsay.handle()
async def _(saying: list[str], image: DepImageBytesOrNone):
if image is not None:
img = PIL.Image.open(BytesIO(image))
else:
img = None
img = await draw_xm("\n".join(saying), img)
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
await xmsay.send(await UniMessage().image(raw=img_bytes).export())

View File

@ -7,22 +7,41 @@ import PIL.Image
from konabot.common.path import ASSETS_PATH from konabot.common.path import ASSETS_PATH
from konabot.common.utils.to_async import make_async from konabot.common.utils.to_async import make_async
from .base.fonts import HARMONYOS_SANS_SC_BLACK, HARMONYOS_SANS_SC_REGULAR, LXGWWENKAI_REGULAR from .base.fonts import (
HARMONYOS_SANS_SC_BLACK,
HARMONYOS_SANS_SC_REGULAR,
LXGWWENKAI_REGULAR,
)
geimao_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "geimao.jpg").convert("RGBA") geimao_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "geimao.jpg").convert(
"RGBA"
)
pt_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "ptsay.png").convert("RGBA") pt_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "ptsay.png").convert("RGBA")
mnk_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "mnksay.jpg").convert("RGBA") mnk_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "mnksay.jpg").convert("RGBA")
dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert("RGBA") dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert("RGBA")
suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert("RGBA") suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert(
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert("RGBA") "RGBA"
)
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert(
"RGBA"
)
kio_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "kiosay.jpg").convert("RGBA") kio_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "kiosay.jpg").convert("RGBA")
vr_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "vr.jpg").convert("RGBA")
xm_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "xiaomu.png").convert("RGBA")
def _draw_geimao(saying: str): def _draw_geimao(saying: str):
img = geimao_image.copy() img = geimao_image.copy()
with imagetext_py.Writer(img) as iw: with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped( iw.draw_text_wrapped(
saying, 960, 50, 0.5, 0, 1920, 240, HARMONYOS_SANS_SC_BLACK, saying,
960,
50,
0.5,
0,
1920,
240,
HARMONYOS_SANS_SC_BLACK,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")), imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
0.8, 0.8,
imagetext_py.TextAlign.Center, imagetext_py.TextAlign.Center,
@ -41,7 +60,14 @@ def _draw_pt(saying: str):
img = pt_image.copy() img = pt_image.copy()
with imagetext_py.Writer(img) as iw: with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped( iw.draw_text_wrapped(
saying, 259, 278, 0.5, 0.5, 360, 48, HARMONYOS_SANS_SC_REGULAR, saying,
259,
278,
0.5,
0.5,
360,
48,
HARMONYOS_SANS_SC_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")), imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0, 1.0,
imagetext_py.TextAlign.Center, imagetext_py.TextAlign.Center,
@ -58,7 +84,14 @@ def _draw_mnk(saying: str):
img = mnk_image.copy() img = mnk_image.copy()
with imagetext_py.Writer(img) as iw: with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped( iw.draw_text_wrapped(
saying, 540, 25, 0.5, 0, 1080, 120, HARMONYOS_SANS_SC_BLACK, saying,
540,
25,
0.5,
0,
1080,
120,
HARMONYOS_SANS_SC_BLACK,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")), imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
0.8, 0.8,
imagetext_py.TextAlign.Center, imagetext_py.TextAlign.Center,
@ -80,7 +113,14 @@ def _draw_suan(saying: str, dasuan: bool = False):
img = suan_image.copy() img = suan_image.copy()
with imagetext_py.Writer(img) as iw: with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped( iw.draw_text_wrapped(
saying, 1020, 290, 0.5, 0.5, 400, 48, LXGWWENKAI_REGULAR, saying,
1020,
290,
0.5,
0.5,
400,
48,
LXGWWENKAI_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")), imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0, 1.0,
imagetext_py.TextAlign.Center, imagetext_py.TextAlign.Center,
@ -97,7 +137,14 @@ def _draw_cute_ten(saying: str):
img = cute_ten_image.copy() img = cute_ten_image.copy()
with imagetext_py.Writer(img) as iw: with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped( iw.draw_text_wrapped(
saying, 390, 479, 0.5, 0.5, 760, 96, LXGWWENKAI_REGULAR, saying,
390,
479,
0.5,
0.5,
760,
96,
LXGWWENKAI_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")), imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0, 1.0,
imagetext_py.TextAlign.Center, imagetext_py.TextAlign.Center,
@ -115,7 +162,14 @@ def draw_kiosay(saying: str):
img = kio_image.copy() img = kio_image.copy()
with imagetext_py.Writer(img) as iw: with imagetext_py.Writer(img) as iw:
iw.draw_text_wrapped( iw.draw_text_wrapped(
saying, 450, 540, 0.5, 0.5, 900, 96, LXGWWENKAI_REGULAR, saying,
450,
540,
0.5,
0.5,
900,
96,
LXGWWENKAI_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")), imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0, 1.0,
imagetext_py.TextAlign.Center, imagetext_py.TextAlign.Center,
@ -123,3 +177,64 @@ def draw_kiosay(saying: str):
) )
return img return img
@make_async
def draw_vr(saying: str):
img = vr_image.copy()
w, h = img.size
hw = 300
img2 = PIL.Image.new("RGBA", (w, h + hw), "white")
img2.paste(img, (0, hw))
with imagetext_py.Writer(img2) as iw:
iw.draw_text_wrapped(
saying,
w // 2,
hw // 2 + 15,
0.5,
0.5,
w,
64,
LXGWWENKAI_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0,
imagetext_py.TextAlign.Center,
draw_emojis=True,
)
return img2
@make_async
def draw_xm(saying: str, image: PIL.Image.Image | None = None):
img_base = PIL.Image.new("RGBA", xm_image.size, (255, 255, 255, 255))
with imagetext_py.Writer(img_base) as iw:
iw.draw_text_wrapped(
saying,
442,
200,
0.5,
0.5,
884,
64,
LXGWWENKAI_REGULAR,
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
1.0,
imagetext_py.TextAlign.Center,
draw_emojis=True,
)
if image is not None:
image_r = image.copy().convert("RGBA")
width, height = image_r.size
base_width = img_base.size[0]
height = int(height / width * base_width)
image_r = image_r.resize((base_width, height))
# try to align center
y = 215 - image_r.height // 2
img_base.paste(image_r, (0, y), mask=image_r)
img_base.paste(xm_image, (0, 0), mask=xm_image)
return img_base

View File

@ -49,8 +49,8 @@ def dump_server_status(name: str, status: JavaStatusResponse | BaseException) ->
async def _(evt: Event, pm: DepPermManager): async def _(evt: Event, pm: DepPermManager):
servers = ( servers = (
(mcstatus.JavaServer("play.simpfun.cn", 11495), "小帕 Bingo"), (mcstatus.JavaServer("play.simpfun.cn", 11495), "小帕 Bingo"),
(mcstatus.JavaServer("bingo.mujica.tech"), "坏枪 Bingo"), # (mcstatus.JavaServer("bingo.mujica.tech"), "坏枪 Bingo"),
(mcstatus.JavaServer("mc.mujica.tech", 11456), "齿轮盛宴"), # (mcstatus.JavaServer("mc.mujica.tech", 11456), "齿轮盛宴"),
) )
responses = await asyncio.gather( responses = await asyncio.gather(

View File

@ -1,44 +0,0 @@
import nonebot
from nonebot.adapters.onebot.v11.bot import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot_plugin_alconna import UniMsg, UniMessage
from pydantic import BaseModel
class NoLuowenConfig(BaseModel):
plugin_noluowen_qqid: int = -1
plugin_noluowen_enable_group: list[int] = []
config = nonebot.get_plugin_config(NoLuowenConfig)
async def is_luowen_mentioned(evt: GroupMessageEvent, msg: UniMsg) -> bool:
if config.plugin_noluowen_qqid <= 0:
return False
if evt.user_id == config.plugin_noluowen_qqid:
return False
if evt.group_id not in config.plugin_noluowen_enable_group:
return False
txt = msg.extract_plain_text()
if "洛温" not in txt:
return False
if "罗文" in txt:
return False
if "阿特金森" in txt:
return False
return True
evt_luowen_mentioned = nonebot.on_message(rule=is_luowen_mentioned)
@evt_luowen_mentioned.handle()
async def _(evt: GroupMessageEvent, bot: Bot):
msg = (
UniMessage()
.reply(str(evt.message_id))
.at(str(config.plugin_noluowen_qqid))
.text(" 好像有人念错了你的 ID")
)
await evt_luowen_mentioned.send(await msg.export(bot=bot))

View File

@ -0,0 +1,64 @@
import random
from nonebot import on_command
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot.rule import to_me
from nonebot_plugin_alconna import UniMessage
from konabot.common.permsys import require_permission
async def make_sleep(event: GroupMessageEvent, bot: Bot, duration: int):
"""
让人睡着
"""
await bot.set_group_ban(
group_id=event.group_id,
user_id=event.user_id,
duration=duration,
)
seconds = duration % 60
minutes = (duration // 60) % 60
hours = duration // 3600
t1 = f"{hours} 小时 {minutes} 分钟 {seconds}"
message = f" 好好睡吧!奖励你 {t1}的睡眠💤"
message = UniMessage.at(str(event.user_id)).text(message)
await message.send(target=event, bot=bot)
cmd_sleep_night = on_command(
"此方晚安",
rule=require_permission("oyasumi") & to_me(),
aliases={"晚安"},
)
@cmd_sleep_night.handle()
async def oyasumi(event: GroupMessageEvent, bot: Bot):
"""
限定只能用 GroupMessageEvent因为它只能在 QQ 群中使用
"""
# 考虑到有人是熬夜很久,所以这里就给一个 3 到 5 小时睡眠的随机数。这个时间内
# 要睡不着我觉得是个小概率事件了!
duration = random.randint(3 * 3600, 5 * 3600)
await make_sleep(event, bot, duration)
await cmd_sleep_night.finish()
cmd_sleep_noon = on_command(
"此方午安",
rule=require_permission("oyasumi") & to_me(),
aliases={"午安"},
)
@cmd_sleep_noon.handle()
async def sleep_noon(event: GroupMessageEvent, bot: Bot):
duration = random.randint(60 * 15, 60 * 30)
await make_sleep(event, bot, duration)
await cmd_sleep_night.finish()

View File

@ -77,7 +77,7 @@ async def get_permission(
perm: str, perm: str,
event: Event, event: Event,
): ):
data = await pm.check_has_permission_info(ec, perm) data = await pm.get_permission_info(ec, perm)
obj_s = f"{ec[0].platform}.{ec[0].entity_type}.{ec[0].external_id}" obj_s = f"{ec[0].platform}.{ec[0].entity_type}.{ec[0].external_id}"

View File

@ -40,8 +40,7 @@ SYSTEM_PROMPT = """你是一个专门解析提醒请求的助手。请分析用
示例: 示例:
用户:"明天下午2点提醒我开会" 用户:"明天下午2点提醒我开会"
输出:{"datetime": "2024-01-16T14:00:00", "datetime_delta": null, 输出:{"datetime": "2024-01-16T14:00:00", "datetime_delta": null, "datetime_delta_minus": false, "content": "开会", "is_notice": true}
"datetime_delta_minus": false, "content": "开会", "is_notice": true}
用户:"5分钟后提醒我关火" 用户:"5分钟后提醒我关火"
输出:{"datetime": null, "datetime_delta": "PT5M", "datetime_delta_minus": false, "content": "关火", "is_notice": true} 输出:{"datetime": null, "datetime_delta": "PT5M", "datetime_delta_minus": false, "content": "关火", "is_notice": true}
@ -95,7 +94,7 @@ async def ask_ai(expression: str, now: datetime.datetime | None = None) -> tuple
message = await llm.chat([ message = await llm.chat([
{ "role": "system", "content": prompt }, { "role": "system", "content": prompt },
{ "role": "user", "content": expression }, { "role": "user", "content": expression },
]) ], extra_body={"enable_thinking": False})
result = message.content result = message.content
if result is None: if result is None:
return (None, "") return (None, "")

View File

@ -3,15 +3,17 @@ from nonebot_plugin_alconna import Alconna, Args, Option, UniMessage, on_alconna
from konabot.common.nb.exc import BotExceptionMessage from konabot.common.nb.exc import BotExceptionMessage
from konabot.plugins.sksl.run_sksl import render_sksl_shader_to_gif from konabot.plugins.sksl.run_sksl import render_sksl_shader_to_gif
cmd_run_sksl = on_alconna(
Alconna(
"shadertool",
Option("--width", Args["width_", int]),
Option("--height", Args["height_", int]),
Option("--duration", Args["duration_", float]),
Option("--fps", Args["fps_", float]),
Args["code", str],
)
)
cmd_run_sksl = on_alconna(Alconna(
"shadertool",
Option("--width", Args["width_", int]),
Option("--height", Args["height_", int]),
Option("--duration", Args["duration_", float]),
Option("--fps", Args["fps_", float]),
Args["code", str],
))
@cmd_run_sksl.handle() @cmd_run_sksl.handle()
async def _( async def _(
@ -34,10 +36,12 @@ async def _(
if width_ > 640 or height_ > 640: if width_ > 640 or height_ > 640:
raise BotExceptionMessage("最大支持 640x640 啦!不要太大啦!") raise BotExceptionMessage("最大支持 640x640 啦!不要太大啦!")
code = code.strip("\"").strip("'") code = code.strip('"').strip("'")
try: try:
res = await render_sksl_shader_to_gif(code, width_, height_, duration_, fps_) res = await render_sksl_shader_to_gif(code, width_, height_, duration_, fps_)
await cmd_run_sksl.send(await UniMessage().image(raw=res).export()) await cmd_run_sksl.send(await UniMessage().image(raw=res).export())
except (ValueError, RuntimeError) as e: except (ValueError, RuntimeError) as e:
await cmd_run_sksl.send(await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export()) await cmd_run_sksl.send(
await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export()
)

View File

@ -24,7 +24,6 @@ def _pack_uniforms(uniforms_dict, width, height, time_val):
# 移除填充字节,使用紧凑布局 # 移除填充字节,使用紧凑布局
return time_bytes + res_bytes return time_bytes + res_bytes
def _render_sksl_shader_to_gif( def _render_sksl_shader_to_gif(
sksl_code: str, sksl_code: str,
width: int = 256, width: int = 256,
@ -152,4 +151,4 @@ async def render_sksl_shader_to_gif(
height, height,
duration, duration,
fps, fps,
) )

View File

@ -1,9 +1,11 @@
import asyncio import asyncio
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import cast from typing import cast
import zipfile
from loguru import logger from loguru import logger
from nonebot import on_command from nonebot import on_command
@ -13,22 +15,101 @@ 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.bot import Bot as OB11Bot
from nonebot.adapters.onebot.v11.message import Message as OB11Message from nonebot.adapters.onebot.v11.message import Message as OB11Message
from konabot.common.artifact import ArtifactDepends, ensure_artifact, register_artifacts
from konabot.common.longtask import DepLongTaskTarget from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import TMP_PATH from konabot.common.path import BINARY_PATH, TMP_PATH
arti_typst_linux = ArtifactDepends(
url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz",
sha256="a6044cbad2a954deb921167e257e120ac0a16b20339ec01121194ff9d394996d",
target=BINARY_PATH / "typst.tar.xz",
required_os="Linux",
required_arch="x86_64",
)
arti_typst_windows = ArtifactDepends(
url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-pc-windows-msvc.zip",
sha256="51353994ac83218c3497052e89b2c432c53b9d4439cdc1b361e2ea4798ebfc13",
target=BINARY_PATH / "typst.zip",
required_os="Windows",
required_arch="AMD64",
)
bin_path: Path | None = None
@arti_typst_linux.on_finished
async def _(downloaded: bool):
logger.debug("安装好了 Linux 版本的 Typst")
global bin_path
tar_path = arti_typst_linux.target
bin_path = BINARY_PATH / "typst"
if downloaded or not bin_path.exists():
bin_path.unlink(missing_ok=True)
process = await asyncio.create_subprocess_exec(
"tar",
"-xvf",
tar_path,
"--strip-components=1",
"-C",
BINARY_PATH,
"typst-x86_64-unknown-linux-musl/typst",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0 or not bin_path.exists():
logger.warning(
"似乎没有成功解压 Typst 二进制文件,检查一下吧! "
f"stdout={stdout} stderr={stderr}"
)
else:
os.chmod(bin_path, 0o755)
@arti_typst_windows.on_finished
async def _(downloaded: bool):
logger.debug("安装好了 Windows 版本的 Typst")
global bin_path
zip_path = arti_typst_windows.target
bin_path = BINARY_PATH / "typst.exe"
if downloaded or not bin_path.exists():
bin_path.unlink(missing_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
target_name = "typst-x86_64-pc-windows-msvc/typst.exe"
if target_name not in zf.namelist():
logger.warning("在 Zip 压缩包里面没有找到目标文件")
return
zf.extract(target_name, BINARY_PATH)
(BINARY_PATH / target_name).rename(bin_path)
(BINARY_PATH / "typst-x86_64-pc-windows-msvc").rmdir()
register_artifacts(arti_typst_linux)
register_artifacts(arti_typst_windows)
TEMPLATE_PATH = Path(__file__).parent / "template.typ" TEMPLATE_PATH = Path(__file__).parent / "template.typ"
TEMPLATE = TEMPLATE_PATH.read_text() TEMPLATE = TEMPLATE_PATH.read_text()
def render_sync(code: str) -> bytes: def render_sync(code: str) -> bytes | None:
global bin_path
if bin_path is None:
return
with TemporaryDirectory(dir=TMP_PATH) as tmpdirname: with TemporaryDirectory(dir=TMP_PATH) as tmpdirname:
temp_dir = Path(tmpdirname).resolve() temp_dir = Path(tmpdirname).resolve()
temp_typ = temp_dir / "page.typ" temp_typ = temp_dir / "page.typ"
temp_typ.write_text(TEMPLATE + "\n\n" + code) temp_typ.write_text(TEMPLATE + "\n\n" + code)
cmd = [ cmd = [
"typst", bin_path,
"compile", "compile",
temp_typ.name, temp_typ.name,
"--format", "--format",
@ -61,7 +142,7 @@ def render_sync(code: str) -> bytes:
return result_png.read_bytes() return result_png.read_bytes()
async def render(code: str) -> bytes: async def render(code: str) -> bytes | None:
task = asyncio.to_thread(lambda: render_sync(code)) task = asyncio.to_thread(lambda: render_sync(code))
return await task return await task
@ -70,7 +151,22 @@ cmd = on_command("typst")
@cmd.handle() @cmd.handle()
async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget): async def _(
evt: Event,
bot: Bot,
msg: UniMsg,
target: DepLongTaskTarget,
):
global bin_path
# 对于本地机器,一般不会在应用启动时自动下载,这里再保证存在
await ensure_artifact(arti_typst_linux)
await ensure_artifact(arti_typst_windows)
if bin_path is None or not bin_path.exists():
logger.warning("当前环境不存在 Typst但仍然调用了")
return
typst_code = "" typst_code = ""
if isinstance(evt, OB11MessageEvent): if isinstance(evt, OB11MessageEvent):
if evt.reply is not None: if evt.reply is not None:
@ -92,6 +188,8 @@ async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget):
try: try:
res = await render(typst_code) res = await render(typst_code)
if res is None:
raise FileNotFoundError("没有渲染出来内容")
except FileNotFoundError as e: except FileNotFoundError as e:
await target.send_message("渲染出错:内部错误") await target.send_message("渲染出错:内部错误")
raise e from e raise e from e

2947
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,6 @@ dependencies = [
"imagetext-py (>=2.2.0,<3.0.0)", "imagetext-py (>=2.2.0,<3.0.0)",
"opencv-python-headless (>=4.12.0.88,<5.0.0.0)", "opencv-python-headless (>=4.12.0.88,<5.0.0.0)",
"returns (>=0.26.0,<0.27.0)", "returns (>=0.26.0,<0.27.0)",
"skia-python (>=138.0,<139.0)",
"nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)", "nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)",
"qrcode (>=8.2,<9.0)", "qrcode (>=8.2,<9.0)",
"nanoid (>=2.0.0,<3.0.0)", "nanoid (>=2.0.0,<3.0.0)",
@ -39,6 +38,9 @@ dependencies = [
"pytest-cov (>=7.0.0,<8.0.0)", "pytest-cov (>=7.0.0,<8.0.0)",
"aiosignal (>=1.4.0,<2.0.0)", "aiosignal (>=1.4.0,<2.0.0)",
"pytest-mock (>=3.15.1,<4.0.0)", "pytest-mock (>=3.15.1,<4.0.0)",
"skia-python (>=144.0.post2,<145.0)",
"moderngl (>=5.12.0,<6.0.0)",
"regex (>=2026.4.4,<2027.0.0)",
] ]
[tool.poetry] [tool.poetry]