Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 988965451b | |||
| f6fadb7226 | |||
| 0d540eea4c | |||
| f21da657db | |||
| a8a7b62f76 | |||
| 789500842c | |||
| 2f22f11d57 | |||
| eff25435e3 | |||
| df28fad697 | |||
| 561f6981aa | |||
| 2632215af9 | |||
| bfde559892 | |||
| 857f8c5955 | |||
| 500053e630 | |||
| 30cfb4cadd | |||
| e2f99af73b | |||
| e09de9eeb6 | |||
| 4a3b49ce79 | |||
| 03900f4416 | |||
| 62f4195e46 | |||
| 751297e3bc | |||
| b450998f3f |
@ -38,6 +38,14 @@ steps:
|
|||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
commands:
|
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_plugin_load.py
|
||||||
|
- name: 在容器中测试 Playwright 工作正常
|
||||||
|
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_playwright.py
|
||||||
- name: 发送构建结果到 ntfy
|
- name: 发送构建结果到 ntfy
|
||||||
image: parrazam/drone-ntfy
|
image: parrazam/drone-ntfy
|
||||||
when:
|
when:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
PORT=21333
|
PORT=21333
|
||||||
|
DATABASE_PATH="./data/database.db"
|
||||||
ENABLE_CONSOLE=true
|
ENABLE_CONSOLE=true
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
/.env
|
/.env
|
||||||
/data
|
/data
|
||||||
|
|
||||||
__pycache__
|
__pycache__
|
||||||
|
/*.diff
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@ -2,7 +2,7 @@ FROM python:3.13-slim AS base
|
|||||||
|
|
||||||
ENV VIRTUAL_ENV=/app/.venv \
|
ENV VIRTUAL_ENV=/app/.venv \
|
||||||
PATH="/app/.venv/bin:$PATH" \
|
PATH="/app/.venv/bin:$PATH" \
|
||||||
PLAYWRIGHT_BROWSERS_PATH=0
|
PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers
|
||||||
|
|
||||||
# 安装所有都需要的底层依赖
|
# 安装所有都需要的底层依赖
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@ -19,7 +19,6 @@ RUN apt-get update && \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
# 安装构建依赖
|
# 安装构建依赖
|
||||||
@ -27,17 +26,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
build-essential cmake git \
|
build-essential cmake git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV POETRY_NO_INTERACTION=1 \
|
RUN pip install --no-cache-dir uv
|
||||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
|
||||||
POETRY_VIRTUALENVS_CREATE=1 \
|
|
||||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN pip install --no-cache-dir poetry
|
|
||||||
|
|
||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
RUN python -m poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
RUN uv sync --no-install-project
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@ -71,12 +71,16 @@ code .
|
|||||||
|
|
||||||
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
||||||
|
|
||||||
|
#### 数据库配置
|
||||||
|
|
||||||
|
本项目使用SQLite作为数据库,默认数据库文件位于`./data/database.db`。可以通过设置`DATABASE_PATH`环境变量来指定其他位置。
|
||||||
|
|
||||||
### 运行
|
### 运行
|
||||||
|
|
||||||
使用命令行手动启动 Bot:
|
使用命令行手动启动 Bot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
poetry run just watch
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
||||||
@ -91,3 +95,7 @@ poetry run python bot.py
|
|||||||
- [事件响应器](https://nonebot.dev/docs/tutorial/matcher)
|
- [事件响应器](https://nonebot.dev/docs/tutorial/matcher)
|
||||||
- [事件处理](https://nonebot.dev/docs/tutorial/handler)
|
- [事件处理](https://nonebot.dev/docs/tutorial/handler)
|
||||||
- [Alconna 插件](https://nonebot.dev/docs/best-practice/alconna/)
|
- [Alconna 插件](https://nonebot.dev/docs/best-practice/alconna/)
|
||||||
|
|
||||||
|
## 数据库模块
|
||||||
|
|
||||||
|
本项目的数据库模块已更新为异步实现,使用连接池来提高性能,并支持现代的`pathlib.Path`参数类型。详细使用方法请参考[数据库使用文档](/docs/database.md)。
|
||||||
|
|||||||
9
bot.py
9
bot.py
@ -10,6 +10,8 @@ from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter
|
|||||||
from konabot.common.log import init_logger
|
from konabot.common.log import init_logger
|
||||||
from konabot.common.nb.exc import BotExceptionMessage
|
from konabot.common.nb.exc import BotExceptionMessage
|
||||||
from konabot.common.path import LOG_PATH
|
from konabot.common.path import LOG_PATH
|
||||||
|
from konabot.common.database import get_global_db_manager
|
||||||
|
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
dotenv.load_dotenv()
|
||||||
env = os.environ.get("ENVIRONMENT", "prod")
|
env = os.environ.get("ENVIRONMENT", "prod")
|
||||||
@ -48,6 +50,13 @@ def main():
|
|||||||
nonebot.load_plugins("konabot/plugins")
|
nonebot.load_plugins("konabot/plugins")
|
||||||
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
|
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
|
||||||
|
|
||||||
|
# 注册关闭钩子
|
||||||
|
@driver.on_shutdown
|
||||||
|
async def shutdown_handler():
|
||||||
|
# 关闭全局数据库管理器
|
||||||
|
db_manager = get_global_db_manager()
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
nonebot.run()
|
nonebot.run()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
223
docs/database.md
Normal file
223
docs/database.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# 数据库系统使用文档
|
||||||
|
|
||||||
|
本文档详细介绍了本项目中使用的异步数据库系统,包括其架构设计、使用方法和最佳实践。
|
||||||
|
|
||||||
|
## 系统概述
|
||||||
|
|
||||||
|
本项目的数据库系统基于 `aiosqlite` 库构建,提供了异步的 SQLite 数据库访问接口。系统主要特性包括:
|
||||||
|
|
||||||
|
1. **异步操作**:完全支持异步/await模式,适配NoneBot2框架
|
||||||
|
2. **连接池**:内置连接池机制,提高数据库访问性能
|
||||||
|
3. **参数化查询**:支持安全的参数化查询,防止SQL注入
|
||||||
|
4. **SQL文件支持**:可以直接执行SQL文件中的脚本
|
||||||
|
5. **类型支持**:支持 `pathlib.Path` 和 `str` 类型的路径参数
|
||||||
|
|
||||||
|
## 核心类和方法
|
||||||
|
|
||||||
|
### DatabaseManager 类
|
||||||
|
|
||||||
|
`DatabaseManager` 是数据库操作的核心类,提供了以下主要方法:
|
||||||
|
|
||||||
|
#### 初始化
|
||||||
|
```python
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 使用默认数据库路径
|
||||||
|
db = DatabaseManager()
|
||||||
|
|
||||||
|
# 指定了义数据库路径
|
||||||
|
db = DatabaseManager("./data/myapp.db")
|
||||||
|
db = DatabaseManager(Path("./data/myapp.db"))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查询操作
|
||||||
|
```python
|
||||||
|
# 执行查询语句并返回结果
|
||||||
|
results = await db.query("SELECT * FROM users WHERE age > ?", (18,))
|
||||||
|
|
||||||
|
# 从SQL文件执行查询
|
||||||
|
results = await db.query_by_sql_file("./sql/get_users.sql", (18,))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 执行操作
|
||||||
|
```python
|
||||||
|
# 执行非查询语句
|
||||||
|
await db.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("张三", "zhangsan@example.com"))
|
||||||
|
|
||||||
|
# 执行SQL脚本(不带参数)
|
||||||
|
await db.execute_script("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE
|
||||||
|
);
|
||||||
|
INSERT INTO users (name, email) VALUES ('测试用户', 'test@example.com');
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 从SQL文件执行非查询语句
|
||||||
|
await db.execute_by_sql_file("./sql/create_tables.sql")
|
||||||
|
|
||||||
|
# 带参数执行SQL文件
|
||||||
|
await db.execute_by_sql_file("./sql/insert_user.sql", ("张三", "zhangsan@example.com"))
|
||||||
|
|
||||||
|
# 执行多条语句(每条语句使用相同参数)
|
||||||
|
await db.execute_many("INSERT INTO users (name, email) VALUES (?, ?)", [
|
||||||
|
("张三", "zhangsan@example.com"),
|
||||||
|
("李四", "lisi@example.com"),
|
||||||
|
("王五", "wangwu@example.com")
|
||||||
|
])
|
||||||
|
|
||||||
|
# 从SQL文件执行多条语句(每条语句使用相同参数)
|
||||||
|
await db.execute_many_values_by_sql_file("./sql/batch_insert.sql", [
|
||||||
|
("张三", "zhangsan@example.com"),
|
||||||
|
("李四", "lisi@example.com")
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQL文件处理机制
|
||||||
|
|
||||||
|
### 单语句SQL文件
|
||||||
|
```sql
|
||||||
|
-- insert_user.sql
|
||||||
|
INSERT INTO users (name, email) VALUES (?, ?);
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 使用方式
|
||||||
|
await db.execute_by_sql_file("./sql/insert_user.sql", ("张三", "zhangsan@example.com"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多语句SQL文件
|
||||||
|
```sql
|
||||||
|
-- setup.sql
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
user_id INTEGER,
|
||||||
|
age INTEGER,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 使用方式
|
||||||
|
await db.execute_by_sql_file("./sql/setup.sql")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多语句带不同参数的SQL文件
|
||||||
|
```sql
|
||||||
|
-- batch_operations.sql
|
||||||
|
INSERT INTO users (name, email) VALUES (?, ?);
|
||||||
|
INSERT INTO profiles (user_id, age) VALUES (?, ?);
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 使用方式
|
||||||
|
await db.execute_by_sql_file("./sql/batch_operations.sql", [
|
||||||
|
("张三", "zhangsan@example.com"), # 第一条语句的参数
|
||||||
|
(1, 25) # 第二条语句的参数
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 数据库表设计
|
||||||
|
```sql
|
||||||
|
-- 推荐的表设计实践
|
||||||
|
CREATE TABLE IF NOT EXISTS example_table (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SQL文件组织
|
||||||
|
建议按照功能模块组织SQL文件:
|
||||||
|
```
|
||||||
|
plugin/
|
||||||
|
├── sql/
|
||||||
|
│ ├── create_tables.sql
|
||||||
|
│ ├── insert_data.sql
|
||||||
|
│ ├── update_data.sql
|
||||||
|
│ └── query_data.sql
|
||||||
|
└── __init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
results = await db.query("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"数据库查询失败: {e}")
|
||||||
|
# 处理错误情况
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 连接管理
|
||||||
|
```python
|
||||||
|
# 在应用启动时初始化
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
|
# 在应用关闭时清理连接
|
||||||
|
async def shutdown():
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 高级特性
|
||||||
|
|
||||||
|
### 连接池配置
|
||||||
|
```python
|
||||||
|
class DatabaseManager:
|
||||||
|
def __init__(self, db_path: Optional[Union[str, Path]] = None):
|
||||||
|
# 连接池大小配置
|
||||||
|
self._pool_size = 5 # 可根据需要调整
|
||||||
|
```
|
||||||
|
|
||||||
|
### 事务支持
|
||||||
|
```python
|
||||||
|
# 通过execute方法的自动提交机制支持事务
|
||||||
|
await db.execute("BEGIN TRANSACTION")
|
||||||
|
try:
|
||||||
|
await db.execute("INSERT INTO users (name) VALUES (?)", ("张三",))
|
||||||
|
await db.execute("INSERT INTO profiles (user_id, age) VALUES (?, ?)", (1, 25))
|
||||||
|
await db.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
await db.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **异步环境**:所有数据库操作都必须在异步环境中执行
|
||||||
|
2. **参数安全**:始终使用参数化查询,避免SQL注入
|
||||||
|
3. **资源管理**:确保在应用关闭时调用 `close_all_connections()`
|
||||||
|
4. **SQL解析**:使用 `sqlparse` 库准确解析SQL语句,正确处理包含分号的字符串和注释
|
||||||
|
5. **错误处理**:适当处理数据库操作可能抛出的异常
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何处理数据库约束错误?
|
||||||
|
A: 确保SQL语句中的字段名正确引用,特别是保留字需要使用双引号包围:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE air_conditioner (
|
||||||
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
|
"on" BOOLEAN NOT NULL, -- 使用双引号包围保留字
|
||||||
|
temperature REAL NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何处理多个语句和参数的匹配?
|
||||||
|
A: 当SQL文件包含多个语句时,参数应该是参数列表,每个语句对应一个参数元组:
|
||||||
|
```python
|
||||||
|
await db.execute_by_sql_file("./sql/batch.sql", [
|
||||||
|
("参数1", "参数2"), # 第一个语句的参数
|
||||||
|
("参数3", "参数4") # 第二个语句的参数
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
通过遵循这些指南和最佳实践,您可以充分利用本项目的异步数据库系统,构建高性能、安全的数据库应用。
|
||||||
4
justfile
Normal file
4
justfile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
watch:
|
||||||
|
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
||||||
|
|
||||||
|
|
||||||
218
konabot/common/database/__init__.py
Normal file
218
konabot/common/database/__init__.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import sqlparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import DatabaseManager
|
||||||
|
|
||||||
|
# 全局数据库管理器实例
|
||||||
|
_global_db_manager: Optional['DatabaseManager'] = None
|
||||||
|
|
||||||
|
def get_global_db_manager() -> 'DatabaseManager':
|
||||||
|
"""获取全局数据库管理器实例"""
|
||||||
|
global _global_db_manager
|
||||||
|
if _global_db_manager is None:
|
||||||
|
from . import DatabaseManager
|
||||||
|
_global_db_manager = DatabaseManager()
|
||||||
|
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:
|
||||||
|
"""异步数据库管理器"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[Union[str, Path]] = None, pool_size: int = 5):
|
||||||
|
"""
|
||||||
|
初始化数据库管理器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: 数据库文件路径,支持str和Path类型
|
||||||
|
pool_size: 连接池大小
|
||||||
|
"""
|
||||||
|
if db_path is None:
|
||||||
|
self.db_path = os.environ.get("DATABASE_PATH", "./data/database.db")
|
||||||
|
else:
|
||||||
|
self.db_path = str(db_path) if isinstance(db_path, Path) else db_path
|
||||||
|
|
||||||
|
# 连接池
|
||||||
|
self._connection_pool = []
|
||||||
|
self._pool_size = pool_size
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._in_use = set() # 跟踪正在使用的连接
|
||||||
|
|
||||||
|
async def _get_connection(self) -> aiosqlite.Connection:
|
||||||
|
"""从连接池获取连接"""
|
||||||
|
async with self._lock:
|
||||||
|
# 尝试从池中获取现有连接
|
||||||
|
while self._connection_pool:
|
||||||
|
conn = self._connection_pool.pop()
|
||||||
|
# 检查连接是否仍然有效
|
||||||
|
try:
|
||||||
|
await conn.execute("SELECT 1")
|
||||||
|
self._in_use.add(conn)
|
||||||
|
return conn
|
||||||
|
except:
|
||||||
|
# 连接已失效,关闭它
|
||||||
|
try:
|
||||||
|
await conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 如果连接池为空,创建新连接
|
||||||
|
conn = await aiosqlite.connect(self.db_path)
|
||||||
|
await conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
self._in_use.add(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
async def _return_connection(self, conn: aiosqlite.Connection) -> None:
|
||||||
|
"""将连接返回到连接池"""
|
||||||
|
async with self._lock:
|
||||||
|
self._in_use.discard(conn)
|
||||||
|
if len(self._connection_pool) < self._pool_size:
|
||||||
|
self._connection_pool.append(conn)
|
||||||
|
else:
|
||||||
|
# 池已满,直接关闭连接
|
||||||
|
try:
|
||||||
|
await conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def query(
|
||||||
|
self, query: str, params: Optional[tuple] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""执行查询语句并返回结果"""
|
||||||
|
conn = await self._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = await conn.execute(query, params or ())
|
||||||
|
columns = [description[0] for description in cursor.description]
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
results = [dict(zip(columns, row)) for row in rows]
|
||||||
|
await cursor.close()
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
# 记录错误但重新抛出,让调用者处理
|
||||||
|
raise Exception(f"数据库查询失败: {str(e)}") from e
|
||||||
|
finally:
|
||||||
|
await self._return_connection(conn)
|
||||||
|
|
||||||
|
async def query_by_sql_file(
|
||||||
|
self, file_path: Union[str, Path], params: Optional[tuple] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""从 SQL 文件中读取查询语句并执行"""
|
||||||
|
path = str(file_path) if isinstance(file_path, Path) else file_path
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
query = f.read()
|
||||||
|
return await self.query(query, params)
|
||||||
|
|
||||||
|
async def execute(self, command: str, params: Optional[tuple] = None) -> None:
|
||||||
|
"""执行非查询语句"""
|
||||||
|
conn = await self._get_connection()
|
||||||
|
try:
|
||||||
|
await conn.execute(command, params or ())
|
||||||
|
await conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# 记录错误但重新抛出,让调用者处理
|
||||||
|
raise Exception(f"数据库执行失败: {str(e)}") from e
|
||||||
|
finally:
|
||||||
|
await self._return_connection(conn)
|
||||||
|
|
||||||
|
async def execute_script(self, script: str) -> None:
|
||||||
|
"""执行SQL脚本"""
|
||||||
|
conn = await self._get_connection()
|
||||||
|
try:
|
||||||
|
await conn.executescript(script)
|
||||||
|
await conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# 记录错误但重新抛出,让调用者处理
|
||||||
|
raise Exception(f"数据库脚本执行失败: {str(e)}") from e
|
||||||
|
finally:
|
||||||
|
await self._return_connection(conn)
|
||||||
|
|
||||||
|
def _parse_sql_statements(self, script: str) -> List[str]:
|
||||||
|
"""解析SQL脚本,分割成独立的语句"""
|
||||||
|
# 使用sqlparse库更准确地分割SQL语句
|
||||||
|
parsed = sqlparse.split(script)
|
||||||
|
statements = []
|
||||||
|
|
||||||
|
for statement in parsed:
|
||||||
|
statement = statement.strip()
|
||||||
|
if statement:
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
return statements
|
||||||
|
|
||||||
|
async def execute_by_sql_file(
|
||||||
|
self, file_path: Union[str, Path], params: Optional[Union[tuple, List[tuple]]] = None
|
||||||
|
) -> None:
|
||||||
|
"""从 SQL 文件中读取非查询语句并执行"""
|
||||||
|
path = str(file_path) if isinstance(file_path, Path) else file_path
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
script = f.read()
|
||||||
|
|
||||||
|
# 如果有参数且是元组,使用execute执行整个脚本
|
||||||
|
if params is not None and isinstance(params, tuple):
|
||||||
|
await self.execute(script, params)
|
||||||
|
# 如果有参数且是列表,分别执行每个语句
|
||||||
|
elif params is not None and isinstance(params, list):
|
||||||
|
# 使用sqlparse准确分割SQL语句
|
||||||
|
statements = self._parse_sql_statements(script)
|
||||||
|
if len(statements) != len(params):
|
||||||
|
raise ValueError(f"语句数量({len(statements)})与参数组数量({len(params)})不匹配")
|
||||||
|
|
||||||
|
for statement, stmt_params in zip(statements, params):
|
||||||
|
if statement:
|
||||||
|
await self.execute(statement, stmt_params)
|
||||||
|
# 如果无参数,使用executescript
|
||||||
|
else:
|
||||||
|
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 with self._lock:
|
||||||
|
# 关闭池中的连接
|
||||||
|
for conn in self._connection_pool:
|
||||||
|
try:
|
||||||
|
await conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._connection_pool.clear()
|
||||||
|
|
||||||
|
# 关闭正在使用的连接
|
||||||
|
for conn in self._in_use.copy():
|
||||||
|
try:
|
||||||
|
await conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._in_use.clear()
|
||||||
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -19,15 +20,21 @@ from PIL import UnidentifiedImageError
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from returns.result import Failure, Result, Success
|
from returns.result import Failure, Result, Success
|
||||||
|
|
||||||
from konabot.common.path import ASSETS_PATH
|
|
||||||
|
|
||||||
|
|
||||||
discordConfig = nonebot.get_plugin_config(DiscordConfig)
|
discordConfig = nonebot.get_plugin_config(DiscordConfig)
|
||||||
|
|
||||||
|
|
||||||
class ExtractImageConfig(BaseModel):
|
class ExtractImageConfig(BaseModel):
|
||||||
module_extract_image_no_download: bool = False
|
module_extract_image_no_download: bool = False
|
||||||
"要不要算了,不下载了,直接爆炸算了,适用于一些比较奇怪的网络环境,无法从协议端下载文件"
|
"""
|
||||||
|
要不要算了,不下载了,直接爆炸算了,
|
||||||
|
适用于一些比较奇怪的网络环境,无法从协议端下载文件
|
||||||
|
"""
|
||||||
|
|
||||||
|
module_extract_image_target: str = './assets/img/other/boom.jpg'
|
||||||
|
"""
|
||||||
|
使用哪个图片呢
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
module_config = nonebot.get_plugin_config(ExtractImageConfig)
|
module_config = nonebot.get_plugin_config(ExtractImageConfig)
|
||||||
@ -37,7 +44,7 @@ async def download_image_bytes(url: str, proxy: str | None = None) -> Result[byt
|
|||||||
# if "/matcha/cache/" in url:
|
# if "/matcha/cache/" in url:
|
||||||
# url = url.replace('127.0.0.1', '10.126.126.101')
|
# url = url.replace('127.0.0.1', '10.126.126.101')
|
||||||
if module_config.module_extract_image_no_download:
|
if module_config.module_extract_image_no_download:
|
||||||
return Success((ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes())
|
return Success(Path(module_config.module_extract_image_target).read_bytes())
|
||||||
logger.debug(f"开始从 {url} 下载图片")
|
logger.debug(f"开始从 {url} 下载图片")
|
||||||
async with httpx.AsyncClient(proxy=proxy) as c:
|
async with httpx.AsyncClient(proxy=proxy) as c:
|
||||||
try:
|
try:
|
||||||
@ -70,15 +77,22 @@ def bytes_to_pil(raw_data: bytes | BytesIO) -> Result[PIL.Image.Image, str]:
|
|||||||
return Failure("图像无法读取,可能是网络存在问题orz")
|
return Failure("图像无法读取,可能是网络存在问题orz")
|
||||||
|
|
||||||
|
|
||||||
async def unimsg_img_to_pil(image: Image) -> Result[PIL.Image.Image, str]:
|
async def unimsg_img_to_bytes(image: Image) -> Result[bytes, str]:
|
||||||
if image.url is not None:
|
if image.url is not None:
|
||||||
raw_result = await download_image_bytes(image.url)
|
raw_result = await download_image_bytes(image.url)
|
||||||
elif image.raw is not None:
|
elif image.raw is not None:
|
||||||
raw_result = Success(image.raw)
|
if isinstance(image.raw, bytes):
|
||||||
|
raw_result = Success(image.raw)
|
||||||
|
else:
|
||||||
|
raw_result = Success(image.raw.getvalue())
|
||||||
else:
|
else:
|
||||||
return Failure("由于一些内部问题,下载图片失败了orz")
|
return Failure("由于一些内部问题,下载图片失败了orz")
|
||||||
|
|
||||||
return raw_result.bind(bytes_to_pil)
|
return raw_result
|
||||||
|
|
||||||
|
|
||||||
|
async def unimsg_img_to_pil(image: Image) -> Result[PIL.Image.Image, str]:
|
||||||
|
return (await unimsg_img_to_bytes(image)).bind(bytes_to_pil)
|
||||||
|
|
||||||
|
|
||||||
async def extract_image_from_qq_message(
|
async def extract_image_from_qq_message(
|
||||||
@ -86,7 +100,7 @@ async def extract_image_from_qq_message(
|
|||||||
evt: OnebotV11MessageEvent,
|
evt: OnebotV11MessageEvent,
|
||||||
bot: OnebotV11Bot,
|
bot: OnebotV11Bot,
|
||||||
allow_reply: bool = True,
|
allow_reply: bool = True,
|
||||||
) -> Result[PIL.Image.Image, str]:
|
) -> Result[bytes, str]:
|
||||||
if allow_reply and (reply := evt.reply) is not None:
|
if allow_reply and (reply := evt.reply) is not None:
|
||||||
return await extract_image_from_qq_message(
|
return await extract_image_from_qq_message(
|
||||||
reply.message,
|
reply.message,
|
||||||
@ -118,18 +132,17 @@ async def extract_image_from_qq_message(
|
|||||||
url = seg.data.get("url")
|
url = seg.data.get("url")
|
||||||
if url is None:
|
if url is None:
|
||||||
return Failure("无法下载图片,可能有一些网络问题")
|
return Failure("无法下载图片,可能有一些网络问题")
|
||||||
data = await download_image_bytes(url)
|
return await download_image_bytes(url)
|
||||||
return data.bind(bytes_to_pil)
|
|
||||||
|
|
||||||
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
||||||
|
|
||||||
|
|
||||||
async def extract_image_from_message(
|
async def extract_image_data_from_message(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
evt: Event,
|
evt: Event,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
allow_reply: bool = True,
|
allow_reply: bool = True,
|
||||||
) -> Result[PIL.Image.Image, str]:
|
) -> Result[bytes, str]:
|
||||||
if (
|
if (
|
||||||
isinstance(bot, OnebotV11Bot)
|
isinstance(bot, OnebotV11Bot)
|
||||||
and isinstance(msg, OnebotV11Message)
|
and isinstance(msg, OnebotV11Message)
|
||||||
@ -145,18 +158,18 @@ async def extract_image_from_message(
|
|||||||
if "image/" not in a.content_type:
|
if "image/" not in a.content_type:
|
||||||
continue
|
continue
|
||||||
url = a.proxy_url
|
url = a.proxy_url
|
||||||
return (await download_image_bytes(url, discordConfig.discord_proxy)).bind(bytes_to_pil)
|
return await download_image_bytes(url, discordConfig.discord_proxy)
|
||||||
|
|
||||||
for seg in UniMessage.of(msg, bot):
|
for seg in UniMessage.of(msg, bot):
|
||||||
logger.info(seg)
|
logger.info(seg)
|
||||||
if isinstance(seg, Image):
|
if isinstance(seg, Image):
|
||||||
return await unimsg_img_to_pil(seg)
|
return await unimsg_img_to_bytes(seg)
|
||||||
elif isinstance(seg, Reply) and allow_reply:
|
elif isinstance(seg, Reply) and allow_reply:
|
||||||
msg2 = seg.msg
|
msg2 = seg.msg
|
||||||
logger.debug(f"深入搜索引用的消息:{msg2}")
|
logger.debug(f"深入搜索引用的消息:{msg2}")
|
||||||
if msg2 is None or isinstance(msg2, str):
|
if msg2 is None or isinstance(msg2, str):
|
||||||
continue
|
continue
|
||||||
return await extract_image_from_message(msg2, evt, bot, False)
|
return await extract_image_data_from_message(msg2, evt, bot, False)
|
||||||
elif isinstance(seg, RefNode) and allow_reply:
|
elif isinstance(seg, RefNode) and allow_reply:
|
||||||
if isinstance(bot, DiscordBot):
|
if isinstance(bot, DiscordBot):
|
||||||
return Failure("暂时不支持在 Discord 中通过引用的方式获取图片")
|
return Failure("暂时不支持在 Discord 中通过引用的方式获取图片")
|
||||||
@ -165,12 +178,12 @@ async def extract_image_from_message(
|
|||||||
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
||||||
|
|
||||||
|
|
||||||
async def _ext_img(
|
async def _ext_img_data(
|
||||||
evt: Event,
|
evt: Event,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
matcher: Matcher,
|
matcher: Matcher,
|
||||||
) -> PIL.Image.Image | None:
|
) -> bytes | None:
|
||||||
match await extract_image_from_message(evt.get_message(), evt, bot):
|
match await extract_image_data_from_message(evt.get_message(), evt, bot):
|
||||||
case Success(img):
|
case Success(img):
|
||||||
return img
|
return img
|
||||||
case Failure(err):
|
case Failure(err):
|
||||||
@ -180,4 +193,20 @@ async def _ext_img(
|
|||||||
assert False
|
assert False
|
||||||
|
|
||||||
|
|
||||||
PIL_Image = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)]
|
async def _ext_img(
|
||||||
|
evt: Event,
|
||||||
|
bot: Bot,
|
||||||
|
matcher: Matcher,
|
||||||
|
) -> PIL.Image.Image | None:
|
||||||
|
r = await _ext_img_data(evt, bot, matcher)
|
||||||
|
if r:
|
||||||
|
match bytes_to_pil(r):
|
||||||
|
case Success(img):
|
||||||
|
return img
|
||||||
|
case Failure(msg):
|
||||||
|
await matcher.send(await UniMessage.text(msg).export())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DepImageBytes = Annotated[bytes, nonebot.params.Depends(_ext_img_data)]
|
||||||
|
DepPILImage = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)]
|
||||||
|
|||||||
76
konabot/common/pager.py
Normal file
76
konabot/common/pager.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from math import ceil
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagerQuery:
|
||||||
|
page_index: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
def apply[T](self, ls: list[T]) -> "PagerResult[T]":
|
||||||
|
if self.page_size <= 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="每页元素数量应该大于 0",
|
||||||
|
data=[],
|
||||||
|
page_count=-1,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
page_count = ceil(len(ls) / self.page_size)
|
||||||
|
if self.page_index <= 0 or self.page_size <= 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="页数必须大于 0",
|
||||||
|
data=[],
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
data = ls[(self.page_index - 1) * self.page_size: self.page_index * self.page_size]
|
||||||
|
if len(data) > 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=True,
|
||||||
|
message="",
|
||||||
|
data=data,
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="指定的页数超过最大页数",
|
||||||
|
data=data,
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagerResult[T]:
|
||||||
|
data: list[T]
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
page_count: int
|
||||||
|
query: PagerQuery
|
||||||
|
|
||||||
|
def to_unimessage(
|
||||||
|
self,
|
||||||
|
formatter: Callable[[T], str | UniMessage[Any]] = str,
|
||||||
|
title: str = '查询结果',
|
||||||
|
list_indicator: str = '- ',
|
||||||
|
) -> UniMessage[Any]:
|
||||||
|
msg = UniMessage.text(f'===== {title} =====\n\n')
|
||||||
|
|
||||||
|
if not self.success:
|
||||||
|
msg = msg.text(f'⚠️ {self.message}\n')
|
||||||
|
else:
|
||||||
|
for obj in self.data:
|
||||||
|
msg = msg.text(list_indicator)
|
||||||
|
msg += formatter(obj)
|
||||||
|
msg += '\n'
|
||||||
|
|
||||||
|
msg = msg.text(f'\n===== 第 {self.query.page_index} 页,共 {self.page_count} 页 =====')
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
653
konabot/common/ptimeparse/__init__.py
Normal file
653
konabot/common/ptimeparse/__init__.py
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import re
|
||||||
|
import datetime
|
||||||
|
from typing import Tuple, Optional, Dict, Any
|
||||||
|
|
||||||
|
from .err import MultipleSpecificationException, TokenUnhandledException
|
||||||
|
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
def __init__(self, now: Optional[datetime.datetime] = None):
|
||||||
|
self.now = now or datetime.datetime.now()
|
||||||
|
|
||||||
|
def digest_chinese_number(self, text: str) -> Tuple[str, int]:
|
||||||
|
if not text:
|
||||||
|
return text, 0
|
||||||
|
# Handle "两" at start
|
||||||
|
if text.startswith("两"):
|
||||||
|
next_char = text[1] if len(text) > 1 else ''
|
||||||
|
if not next_char or next_char in "十百千万亿":
|
||||||
|
return text[1:], 2
|
||||||
|
s = "零一二三四五六七八九"
|
||||||
|
digits = {c: i for i, c in enumerate(s)}
|
||||||
|
i = 0
|
||||||
|
while i < len(text) and text[i] in s + "十百千万亿":
|
||||||
|
i += 1
|
||||||
|
if i == 0:
|
||||||
|
return text, 0
|
||||||
|
num_str = text[:i]
|
||||||
|
rest = text[i:]
|
||||||
|
|
||||||
|
def parse(s):
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
if s == "零":
|
||||||
|
return 0
|
||||||
|
if "亿" in s:
|
||||||
|
a, b = s.split("亿", 1)
|
||||||
|
return parse(a) * 100000000 + parse(b)
|
||||||
|
if "万" in s:
|
||||||
|
a, b = s.split("万", 1)
|
||||||
|
return parse(a) * 10000 + parse(b)
|
||||||
|
n = 0
|
||||||
|
t = 0
|
||||||
|
for c in s:
|
||||||
|
if c == "零":
|
||||||
|
continue
|
||||||
|
if c in digits:
|
||||||
|
t = digits[c]
|
||||||
|
elif c == "十":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 10
|
||||||
|
t = 0
|
||||||
|
elif c == "百":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 100
|
||||||
|
t = 0
|
||||||
|
elif c == "千":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 1000
|
||||||
|
t = 0
|
||||||
|
n += t
|
||||||
|
return n
|
||||||
|
|
||||||
|
return rest, parse(num_str)
|
||||||
|
|
||||||
|
def parse(self, text: str) -> datetime.datetime:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
raise TokenUnhandledException("Empty input")
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"date": None,
|
||||||
|
"time": None,
|
||||||
|
"relative_delta": None,
|
||||||
|
"am_pm": None,
|
||||||
|
"period_word": None,
|
||||||
|
"has_time": False,
|
||||||
|
"has_date": False,
|
||||||
|
"ambiguous_hour": False,
|
||||||
|
"is_24hour": False,
|
||||||
|
"has_relative_date": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = self._parse_all(text, ctx)
|
||||||
|
if rest.strip():
|
||||||
|
raise TokenUnhandledException(f"Unparsed tokens: {rest.strip()}")
|
||||||
|
|
||||||
|
return self._apply_context(ctx)
|
||||||
|
|
||||||
|
def _parse_all(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
rest = text.lstrip()
|
||||||
|
while True:
|
||||||
|
for parser in [
|
||||||
|
self._parse_absolute_date,
|
||||||
|
self._parse_relative_date,
|
||||||
|
self._parse_relative_time,
|
||||||
|
self._parse_period,
|
||||||
|
self._parse_time,
|
||||||
|
]:
|
||||||
|
new_rest = parser(rest, ctx)
|
||||||
|
if new_rest != rest:
|
||||||
|
rest = new_rest.lstrip()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return rest
|
||||||
|
|
||||||
|
def _add_delta(self, ctx, delta):
|
||||||
|
if ctx["relative_delta"] is None:
|
||||||
|
ctx["relative_delta"] = delta
|
||||||
|
else:
|
||||||
|
ctx["relative_delta"] += delta
|
||||||
|
|
||||||
|
def _parse_absolute_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d, h, minute = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["time"] = datetime.time(h, minute)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["is_24hour"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})/(\d{1,2})/(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})年(\d{1,2})月(\d{1,2})[日号]", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{1,2})月(\d{1,2})[日号]", text)
|
||||||
|
if m:
|
||||||
|
mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(.{1,3})月(.{1,3})[日号]", text)
|
||||||
|
if m:
|
||||||
|
m_str, d_str = m.groups()
|
||||||
|
_, mth = self.digest_chinese_number(m_str)
|
||||||
|
_, d = self.digest_chinese_number(d_str)
|
||||||
|
if mth == 0:
|
||||||
|
mth = 1
|
||||||
|
if d == 0:
|
||||||
|
d = 1
|
||||||
|
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_relative_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# Handle "今天", "今晚", "今早", etc.
|
||||||
|
today_variants = [
|
||||||
|
("今晚上", "PM"),
|
||||||
|
("今晚", "PM"),
|
||||||
|
("今早", "AM"),
|
||||||
|
("今天早上", "AM"),
|
||||||
|
("今天早晨", "AM"),
|
||||||
|
("今天上午", "AM"),
|
||||||
|
("今天下午", "PM"),
|
||||||
|
("今天晚上", "PM"),
|
||||||
|
("今天", None),
|
||||||
|
]
|
||||||
|
for variant, period in today_variants:
|
||||||
|
if text.startswith(variant):
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=0))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
rest = text[len(variant):]
|
||||||
|
if period is not None and ctx["am_pm"] is None:
|
||||||
|
ctx["am_pm"] = period
|
||||||
|
ctx["period_word"] = variant
|
||||||
|
return rest
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"明天": 1,
|
||||||
|
"后天": 2,
|
||||||
|
"大后天": 3,
|
||||||
|
"昨天": -1,
|
||||||
|
"前天": -2,
|
||||||
|
"大前天": -3,
|
||||||
|
}
|
||||||
|
for word, days in mapping.items():
|
||||||
|
if text.startswith(word):
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[len(word):]
|
||||||
|
m = re.match(r"^(\d+|[零一二三四五六七八九十两]+)天(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
n = int(num_str)
|
||||||
|
else:
|
||||||
|
_, n = self.digest_chinese_number(num_str)
|
||||||
|
days = n if direction in ("后", "以后", "之后") else -n
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(本|上|下)周([一二三四五六日])", text)
|
||||||
|
if m:
|
||||||
|
scope, day = m.groups()
|
||||||
|
weekday_map = {"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6}
|
||||||
|
target = weekday_map[day]
|
||||||
|
current = self.now.weekday()
|
||||||
|
if scope == "本":
|
||||||
|
delta = target - current
|
||||||
|
elif scope == "上":
|
||||||
|
delta = target - current - 7
|
||||||
|
else:
|
||||||
|
delta = target - current + 7
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=delta))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_period(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
period_mapping = {
|
||||||
|
"上午": "AM",
|
||||||
|
"早晨": "AM",
|
||||||
|
"早上": "AM",
|
||||||
|
"早": "AM",
|
||||||
|
"中午": "PM",
|
||||||
|
"下午": "PM",
|
||||||
|
"晚上": "PM",
|
||||||
|
"晚": "PM",
|
||||||
|
"凌晨": "AM",
|
||||||
|
}
|
||||||
|
for word, tag in period_mapping.items():
|
||||||
|
if text.startswith(word):
|
||||||
|
if ctx["am_pm"] is not None:
|
||||||
|
raise MultipleSpecificationException("Multiple periods")
|
||||||
|
ctx["am_pm"] = tag
|
||||||
|
ctx["period_word"] = word
|
||||||
|
return text[len(word):]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
if ctx["has_time"]:
|
||||||
|
return text
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# 1. H:MM pattern
|
||||||
|
m = re.match(r"^(\d{1,2}):(\d{2})", text)
|
||||||
|
if m:
|
||||||
|
h, minute = int(m.group(1)), int(m.group(2))
|
||||||
|
if 0 <= h <= 23 and 0 <= minute <= 59:
|
||||||
|
ctx["time"] = datetime.time(h, minute)
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["ambiguous_hour"] = 1 <= h <= 12
|
||||||
|
ctx["is_24hour"] = h > 12 or h == 0
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# 2. Parse hour part
|
||||||
|
hour = None
|
||||||
|
rest_after_hour = text
|
||||||
|
is_24hour_format = False
|
||||||
|
|
||||||
|
# Try Chinese number + 点/时
|
||||||
|
temp_rest, num = self.digest_chinese_number(text)
|
||||||
|
if num >= 0:
|
||||||
|
temp_rest_stripped = temp_rest.lstrip()
|
||||||
|
if temp_rest_stripped.startswith("点"):
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = temp_rest_stripped[1:]
|
||||||
|
elif temp_rest_stripped.startswith("时"):
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = True
|
||||||
|
rest_after_hour = temp_rest_stripped[1:]
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
m = re.match(r"^(\d{1,2})\s*([点时])", text)
|
||||||
|
if m:
|
||||||
|
hour = int(m.group(1))
|
||||||
|
is_24hour_format = m.group(2) == "时"
|
||||||
|
rest_after_hour = text[m.end():]
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
if ctx.get("am_pm") is not None:
|
||||||
|
temp_rest, num = self.digest_chinese_number(text)
|
||||||
|
if 0 <= num <= 23:
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = temp_rest.lstrip()
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
h_val = int(m.group(1))
|
||||||
|
if 0 <= h_val <= 23:
|
||||||
|
hour = h_val
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = text[m.end():].lstrip()
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
return text
|
||||||
|
|
||||||
|
if not (0 <= hour <= 23):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Parse minutes
|
||||||
|
rest = rest_after_hour.lstrip()
|
||||||
|
minute = 0
|
||||||
|
minute_spec_count = 0
|
||||||
|
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
has_zheng = False
|
||||||
|
if rest.startswith("整"):
|
||||||
|
has_zheng = True
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("半"):
|
||||||
|
minute = 30
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
if rest.startswith("整"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("一刻"):
|
||||||
|
minute = 15
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[2:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("过一刻"):
|
||||||
|
minute = 15
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[3:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
m = re.match(r"^(\d+|[零一二三四五六七八九十]+)分", rest)
|
||||||
|
if m:
|
||||||
|
minute_spec_count += 1
|
||||||
|
m_str = m.group(1)
|
||||||
|
if m_str.isdigit():
|
||||||
|
minute = int(m_str)
|
||||||
|
else:
|
||||||
|
_, minute = self.digest_chinese_number(m_str)
|
||||||
|
rest = rest[m.end():].lstrip()
|
||||||
|
|
||||||
|
if minute_spec_count == 0:
|
||||||
|
temp_rest, num = self.digest_chinese_number(rest)
|
||||||
|
if num > 0 and num <= 59:
|
||||||
|
minute = num
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = temp_rest.lstrip()
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})", rest)
|
||||||
|
if m:
|
||||||
|
m_val = int(m.group(1))
|
||||||
|
if 0 <= m_val <= 59:
|
||||||
|
minute = m_val
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[m.end():].lstrip()
|
||||||
|
|
||||||
|
if has_zheng and minute_spec_count == 0:
|
||||||
|
minute_spec_count = 1
|
||||||
|
|
||||||
|
if minute_spec_count > 1:
|
||||||
|
raise MultipleSpecificationException("Multiple minute specifications")
|
||||||
|
|
||||||
|
if not (0 <= minute <= 59):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Hours 13-23 are always 24-hour, even with "点"
|
||||||
|
if hour >= 13:
|
||||||
|
is_24hour_format = True
|
||||||
|
|
||||||
|
ctx["time"] = datetime.time(hour, minute)
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["ambiguous_hour"] = 1 <= hour <= 12 and not is_24hour_format
|
||||||
|
ctx["is_24hour"] = is_24hour_format
|
||||||
|
|
||||||
|
return rest
|
||||||
|
|
||||||
|
def _parse_relative_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# 半小时
|
||||||
|
m = re.match(r"^(半)(?:个)?小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
direction = m.group(2)
|
||||||
|
hours = 0.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X个半
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)个半(?:小时?)?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
base_hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, base_hours = self.digest_chinese_number(num_str)
|
||||||
|
if base_hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if base_hours <= 0:
|
||||||
|
return text
|
||||||
|
hours = base_hours + 0.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# 一个半
|
||||||
|
m = re.match(r"^(一个半)小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
direction = m.group(2)
|
||||||
|
hours = 1.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X小时
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, hours = self.digest_chinese_number(num_str)
|
||||||
|
if hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if hours <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, hours = self.digest_chinese_number(num_str)
|
||||||
|
if hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if hours <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction == "后" else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X分钟
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction == "后" else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction == "后" else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# === 秒级支持 ===
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
seconds = int(num_str)
|
||||||
|
else:
|
||||||
|
_, seconds = self.digest_chinese_number(num_str)
|
||||||
|
if seconds == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if seconds <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
seconds=seconds if direction in ("后", "以后", "之后") else -seconds
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
seconds = int(num_str)
|
||||||
|
else:
|
||||||
|
_, seconds = self.digest_chinese_number(num_str)
|
||||||
|
if seconds == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if seconds <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
seconds=seconds if direction == "后" else -seconds
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _apply_context(self, ctx: Dict[str, Any]) -> datetime.datetime:
|
||||||
|
result = self.now
|
||||||
|
has_date = ctx["has_date"]
|
||||||
|
has_time = ctx["has_time"]
|
||||||
|
has_delta = ctx["relative_delta"] is not None
|
||||||
|
has_relative_date = ctx["has_relative_date"]
|
||||||
|
|
||||||
|
if has_delta:
|
||||||
|
result = result + ctx["relative_delta"]
|
||||||
|
|
||||||
|
if has_date:
|
||||||
|
result = result.replace(
|
||||||
|
year=ctx["date"].year,
|
||||||
|
month=ctx["date"].month,
|
||||||
|
day=ctx["date"].day,
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_time:
|
||||||
|
h = ctx["time"].hour
|
||||||
|
m = ctx["time"].minute
|
||||||
|
|
||||||
|
if ctx["is_24hour"]:
|
||||||
|
# "10 时" → 10:00, no conversion
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif ctx["am_pm"] == "AM":
|
||||||
|
if h == 12:
|
||||||
|
h = 0
|
||||||
|
|
||||||
|
elif ctx["am_pm"] == "PM":
|
||||||
|
if h == 12:
|
||||||
|
if ctx.get("period_word") in ("晚上", "晚"):
|
||||||
|
h = 0
|
||||||
|
result += datetime.timedelta(days=1)
|
||||||
|
else:
|
||||||
|
h = 12
|
||||||
|
elif 1 <= h <= 11:
|
||||||
|
h += 12
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No period and not 24-hour (i.e., "点" format)
|
||||||
|
if ctx["has_relative_date"]:
|
||||||
|
# "明天五点" → 05:00 AM
|
||||||
|
if h == 12:
|
||||||
|
h = 0
|
||||||
|
# keep h as AM hour (1-11 unchanged)
|
||||||
|
else:
|
||||||
|
# Infer from current time
|
||||||
|
am_hour = 0 if h == 12 else h
|
||||||
|
candidate_am = result.replace(hour=am_hour, minute=m, second=0, microsecond=0)
|
||||||
|
if candidate_am < self.now:
|
||||||
|
# AM time is in the past, so use PM
|
||||||
|
if h == 12:
|
||||||
|
h = 12
|
||||||
|
else:
|
||||||
|
h += 12
|
||||||
|
# else: keep as AM (h unchanged)
|
||||||
|
|
||||||
|
if h > 23:
|
||||||
|
h = h % 24
|
||||||
|
|
||||||
|
result = result.replace(hour=h, minute=m, second=0, microsecond=0)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if has_date or (has_relative_date and not has_time):
|
||||||
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text: str) -> datetime.datetime:
|
||||||
|
return Parser().parse(text)
|
||||||
11
konabot/common/ptimeparse/err.py
Normal file
11
konabot/common/ptimeparse/err.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class PTimeParseException(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
class TokenUnhandledException(PTimeParseException):
|
||||||
|
...
|
||||||
|
|
||||||
|
class MultipleSpecificationException(PTimeParseException):
|
||||||
|
...
|
||||||
|
|
||||||
|
class OutOfRangeSpecificationException(PTimeParseException):
|
||||||
|
...
|
||||||
@ -5,6 +5,7 @@ from pydantic import BaseModel
|
|||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
module_web_render_weburl: str = "localhost:5173"
|
module_web_render_weburl: str = "localhost:5173"
|
||||||
module_web_render_instance: str = ""
|
module_web_render_instance: str = ""
|
||||||
|
module_web_render_playwright_ws: str = ""
|
||||||
|
|
||||||
def get_instance_baseurl(self):
|
def get_instance_baseurl(self):
|
||||||
if self.module_web_render_instance:
|
if self.module_web_render_instance:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
import queue
|
import queue
|
||||||
from typing import Any, Callable, Coroutine
|
from typing import Any, Callable, Coroutine, Generic, TypeVar
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from playwright.async_api import (
|
from playwright.async_api import (
|
||||||
Page,
|
Page,
|
||||||
@ -8,9 +9,14 @@ from playwright.async_api import (
|
|||||||
async_playwright,
|
async_playwright,
|
||||||
Browser,
|
Browser,
|
||||||
BrowserContext,
|
BrowserContext,
|
||||||
|
Error as PlaywrightError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .config import web_render_config
|
||||||
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
TFunction = Callable[[T], Coroutine[Any, Any, Any]]
|
||||||
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
||||||
|
|
||||||
|
|
||||||
@ -22,23 +28,17 @@ class WebRenderer:
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_browser_instance(cls) -> "WebRendererInstance":
|
async def get_browser_instance(cls) -> "WebRendererInstance":
|
||||||
if cls.browser_pool.empty():
|
if cls.browser_pool.empty():
|
||||||
instance = await WebRendererInstance.create()
|
if web_render_config.module_web_render_playwright_ws:
|
||||||
|
instance = await RemotePlaywrightInstance.create(
|
||||||
|
web_render_config.module_web_render_playwright_ws
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instance = await LocalPlaywrightInstance.create()
|
||||||
cls.browser_pool.put(instance)
|
cls.browser_pool.put(instance)
|
||||||
instance = cls.browser_pool.get()
|
instance = cls.browser_pool.get()
|
||||||
cls.browser_pool.put(instance)
|
cls.browser_pool.put(instance)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_browser_context(cls) -> BrowserContext:
|
|
||||||
instance = await cls.get_browser_instance()
|
|
||||||
if id(instance) not in cls.context_pool:
|
|
||||||
context = await instance.browser.new_context()
|
|
||||||
cls.context_pool[id(instance)] = context
|
|
||||||
logger.debug(
|
|
||||||
f"Created new persistent browser context for WebRendererInstance {id(instance)}"
|
|
||||||
)
|
|
||||||
return cls.context_pool[id(instance)]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def render(
|
async def render(
|
||||||
cls,
|
cls,
|
||||||
@ -67,49 +67,6 @@ class WebRenderer:
|
|||||||
url, target, params=params, other_function=other_function, timeout=timeout
|
url, target, params=params, other_function=other_function, timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def render_persistent_page(
|
|
||||||
cls,
|
|
||||||
page_id: str,
|
|
||||||
url: str,
|
|
||||||
target: str,
|
|
||||||
params: dict = {},
|
|
||||||
other_function: PageFunction | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> bytes:
|
|
||||||
"""
|
|
||||||
使用长期挂载的页面访问指定URL并返回截图
|
|
||||||
|
|
||||||
:param page_id: 页面唯一标识符
|
|
||||||
:param url: 目标URL
|
|
||||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
|
||||||
:param timeout: 页面加载超时时间,单位秒
|
|
||||||
:param params: URL键值对参数
|
|
||||||
:param other_function: 其他自定义操作函数,接受page参数
|
|
||||||
:return: 截图的字节数据
|
|
||||||
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"Requesting persistent render for page_id {page_id} at {url} targeting {target} with timeout {timeout}"
|
|
||||||
)
|
|
||||||
instance = await cls.get_browser_instance()
|
|
||||||
if page_id not in cls.page_pool:
|
|
||||||
context = await cls.get_browser_context()
|
|
||||||
page = await context.new_page()
|
|
||||||
cls.page_pool[page_id] = page
|
|
||||||
logger.debug(
|
|
||||||
f"Created new persistent page for page_id {page_id} using WebRendererInstance {id(instance)}"
|
|
||||||
)
|
|
||||||
page = cls.page_pool[page_id]
|
|
||||||
return await instance.render_with_page(
|
|
||||||
page,
|
|
||||||
url,
|
|
||||||
target,
|
|
||||||
params=params,
|
|
||||||
other_function=other_function,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def render_file(
|
async def render_file(
|
||||||
cls,
|
cls,
|
||||||
@ -142,6 +99,75 @@ class WebRenderer:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def render_with_persistent_page(
|
||||||
|
cls,
|
||||||
|
page_id: str,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
params: dict = {},
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
使用长期挂载的页面进行渲染
|
||||||
|
|
||||||
|
:param page_id: 页面唯一标识符
|
||||||
|
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||||
|
:param timeout: 页面加载超时时间,单位秒
|
||||||
|
:param params: URL键值对参数
|
||||||
|
:param other_function: 其他自定义操作函数,接受page参数
|
||||||
|
:return: 截图的字节数据
|
||||||
|
|
||||||
|
"""
|
||||||
|
instance = await cls.get_browser_instance()
|
||||||
|
logger.debug(
|
||||||
|
f"Using WebRendererInstance {id(instance)} to render with persistent page {page_id} targeting {target}"
|
||||||
|
)
|
||||||
|
return await instance.render_with_persistent_page(
|
||||||
|
page_id,
|
||||||
|
url,
|
||||||
|
target,
|
||||||
|
params=params,
|
||||||
|
other_function=other_function,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_persistent_page(cls, page_id: str, url: str) -> Page:
|
||||||
|
"""
|
||||||
|
获取长期挂载的页面,如果不存在则创建一个新的页面并存储
|
||||||
|
"""
|
||||||
|
if page_id in cls.page_pool:
|
||||||
|
return cls.page_pool[page_id]
|
||||||
|
|
||||||
|
async def on_console(msg: ConsoleMessage):
|
||||||
|
logger.debug(f"WEB CONSOLE {msg.text}")
|
||||||
|
|
||||||
|
instance = await cls.get_browser_instance()
|
||||||
|
if isinstance(instance, RemotePlaywrightInstance):
|
||||||
|
context = await instance.browser.new_context()
|
||||||
|
page = await context.new_page()
|
||||||
|
await page.goto(url)
|
||||||
|
cls.page_pool[page_id] = page
|
||||||
|
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||||
|
|
||||||
|
page.on('console', on_console)
|
||||||
|
|
||||||
|
return page
|
||||||
|
elif isinstance(instance, LocalPlaywrightInstance):
|
||||||
|
context = await instance.browser.new_context()
|
||||||
|
page = await context.new_page()
|
||||||
|
await page.goto(url)
|
||||||
|
cls.page_pool[page_id] = page
|
||||||
|
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||||
|
|
||||||
|
page.on('console', on_console)
|
||||||
|
|
||||||
|
return page
|
||||||
|
else:
|
||||||
|
raise NotImplementedError("Unsupported WebRendererInstance type")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def close_persistent_page(cls, page_id: str) -> None:
|
async def close_persistent_page(cls, page_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
@ -156,38 +182,56 @@ class WebRenderer:
|
|||||||
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
|
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
|
||||||
|
|
||||||
|
|
||||||
class WebRendererInstance:
|
class WebRendererInstance(ABC, Generic[T]):
|
||||||
def __init__(self):
|
@abstractmethod
|
||||||
self._playwright: Playwright | None = None
|
async def render(
|
||||||
self._browser: Browser | None = None
|
self,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
index: int = 0,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
other_function: TFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def render_file(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
target: str,
|
||||||
|
index: int = 0,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def render_with_persistent_page(
|
||||||
|
self,
|
||||||
|
page_id: str,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
params: dict = {},
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PlaywrightInstance(WebRendererInstance[Page]):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playwright(self) -> Playwright:
|
@abstractmethod
|
||||||
assert self._playwright is not None
|
def browser(self) -> Browser: ...
|
||||||
return self._playwright
|
|
||||||
|
|
||||||
@property
|
|
||||||
def browser(self) -> Browser:
|
|
||||||
assert self._browser is not None
|
|
||||||
return self._browser
|
|
||||||
|
|
||||||
async def init(self):
|
|
||||||
self._playwright = await async_playwright().start()
|
|
||||||
self._browser = await self.playwright.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def create(cls) -> "WebRendererInstance":
|
|
||||||
instance = cls()
|
|
||||||
await instance.init()
|
|
||||||
return instance
|
|
||||||
|
|
||||||
async def render(
|
async def render(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
target: str,
|
target: str,
|
||||||
index: int = 0,
|
index: int = 0,
|
||||||
params: dict = {},
|
params: dict[str, Any] | None = None,
|
||||||
other_function: PageFunction | None = None,
|
other_function: PageFunction | None = None,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
@ -207,42 +251,41 @@ class WebRendererInstance:
|
|||||||
context = await self.browser.new_context()
|
context = await self.browser.new_context()
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
screenshot = await self.inner_render(
|
screenshot = await self.inner_render(
|
||||||
page, url, target, index, params, other_function, timeout
|
page, url, target, index, params or {}, other_function, timeout
|
||||||
)
|
)
|
||||||
await page.close()
|
await page.close()
|
||||||
await context.close()
|
await context.close()
|
||||||
return screenshot
|
return screenshot
|
||||||
|
|
||||||
async def render_with_page(
|
|
||||||
self,
|
|
||||||
page: Page,
|
|
||||||
url: str,
|
|
||||||
target: str,
|
|
||||||
index: int = 0,
|
|
||||||
params: dict = {},
|
|
||||||
other_function: PageFunction | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> bytes:
|
|
||||||
async with self.lock:
|
|
||||||
screenshot = await self.inner_render(
|
|
||||||
page, url, target, index, params, other_function, timeout
|
|
||||||
)
|
|
||||||
return screenshot
|
|
||||||
|
|
||||||
async def render_file(
|
async def render_file(
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
target: str,
|
target: str,
|
||||||
index: int = 0,
|
index: int = 0,
|
||||||
params: dict = {},
|
params: dict[str, Any] | None = None,
|
||||||
other_function: PageFunction | None = None,
|
other_function: PageFunction | None = None,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
file_path = "file:///" + str(file_path).replace("\\", "/")
|
file_path = "file:///" + str(file_path).replace("\\", "/")
|
||||||
return await self.render(
|
return await self.render(
|
||||||
file_path, target, index, params, other_function, timeout
|
file_path, target, index, params or {}, other_function, timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def render_with_persistent_page(
|
||||||
|
self,
|
||||||
|
page_id: str,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
params: dict = {},
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes:
|
||||||
|
page = await WebRenderer.get_persistent_page(page_id, url)
|
||||||
|
screenshot = await self.inner_render(
|
||||||
|
page, url, target, 0, params, other_function, timeout
|
||||||
|
)
|
||||||
|
return screenshot
|
||||||
|
|
||||||
async def inner_render(
|
async def inner_render(
|
||||||
self,
|
self,
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -276,6 +319,85 @@ class WebRendererInstance:
|
|||||||
logger.debug("Screenshot taken successfully")
|
logger.debug("Screenshot taken successfully")
|
||||||
return screenshot
|
return screenshot
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPlaywrightInstance(PlaywrightInstance):
|
||||||
|
def __init__(self):
|
||||||
|
self._playwright: Playwright | None = None
|
||||||
|
self._browser: Browser | None = None
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playwright(self) -> Playwright:
|
||||||
|
assert self._playwright is not None
|
||||||
|
return self._playwright
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browser(self) -> Browser:
|
||||||
|
assert self._browser is not None
|
||||||
|
return self._browser
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
self._playwright = await async_playwright().start()
|
||||||
|
self._browser = await self.playwright.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls) -> "WebRendererInstance":
|
||||||
|
instance = cls()
|
||||||
|
await instance.init()
|
||||||
|
return instance
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
await self.browser.close()
|
await self.browser.close()
|
||||||
await self.playwright.stop()
|
await self.playwright.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class RemotePlaywrightInstance(PlaywrightInstance):
|
||||||
|
def __init__(self, ws_endpoint: str) -> None:
|
||||||
|
self._playwright: Playwright | None = None
|
||||||
|
self._browser: Browser | None = None
|
||||||
|
self._ws_endpoint = ws_endpoint
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playwright(self) -> Playwright:
|
||||||
|
assert self._playwright is not None, "Playwright must be initialized by calling init()."
|
||||||
|
return self._playwright
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browser(self) -> Browser:
|
||||||
|
assert self._browser is not None, "Browser must be connected by calling init()."
|
||||||
|
return self._browser
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
logger.info(f"尝试连接远程 Playwright 服务器: {self._ws_endpoint}")
|
||||||
|
self._playwright = await async_playwright().start()
|
||||||
|
try:
|
||||||
|
self._browser = await self.playwright.chromium.connect(
|
||||||
|
self._ws_endpoint
|
||||||
|
)
|
||||||
|
logger.info("成功连接到远程 Playwright 服务器。")
|
||||||
|
except PlaywrightError as e:
|
||||||
|
await self.playwright.stop()
|
||||||
|
raise ConnectionError(
|
||||||
|
f"无法连接到远程 Playwright 服务器 ({self._ws_endpoint}):{e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, ws_endpoint: str) -> "RemotePlaywrightInstance":
|
||||||
|
"""
|
||||||
|
创建并初始化远程 Playwright 实例的工厂方法。
|
||||||
|
"""
|
||||||
|
instance = cls(ws_endpoint)
|
||||||
|
await instance.init()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""
|
||||||
|
断开与远程浏览器的连接并停止本地 Playwright 实例。
|
||||||
|
"""
|
||||||
|
if self._browser:
|
||||||
|
await self.browser.close()
|
||||||
|
if self._playwright:
|
||||||
|
await self.playwright.stop()
|
||||||
|
print("已断开远程连接,本地 Playwright 实例已停止。")
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
关于「中间答案」或者「提示」:
|
# 关于「中间答案」或者「提示」
|
||||||
|
|
||||||
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
|
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
关于罗文和洛温:
|
# 关于罗文和洛温
|
||||||
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。
|
|
||||||
|
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。
|
||||||
|
|||||||
@ -1,45 +1,48 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
is_admin - 用于判断当前事件是否来自管理员的内部权限校验函数
|
|
||||||
|
|
||||||
格式
|
`is_admin` - 用于判断当前事件是否来自管理员的内部权限校验函数
|
||||||
from konabot.common.nb.is_admin import is_admin
|
|
||||||
from nonebot import on
|
|
||||||
from nonebot.adapters import Event
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
@on().handle()
|
# 格式
|
||||||
async def _(event: Event):
|
|
||||||
if is_admin(event):
|
|
||||||
logger.info("管理员发送了消息")
|
|
||||||
|
|
||||||
说明
|
```python
|
||||||
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
from konabot.common.nb.is_admin import is_admin
|
||||||
|
from nonebot import on
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
@on().handle()
|
||||||
|
async def _(event: Event):
|
||||||
|
if is_admin(event):
|
||||||
|
logger.info("管理员发送了消息")
|
||||||
|
```
|
||||||
|
|
||||||
|
# 说明
|
||||||
|
|
||||||
|
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
||||||
|
|
||||||
支持的适配器与判定逻辑:
|
支持的适配器与判定逻辑:
|
||||||
• OneBot V11(QQ)
|
|
||||||
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
|
||||||
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
|
||||||
|
|
||||||
• Discord
|
- OneBot V11(QQ)
|
||||||
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
||||||
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
||||||
|
- Discord
|
||||||
|
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
||||||
|
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
||||||
|
- Console(控制台)
|
||||||
|
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
||||||
|
|
||||||
• Console(控制台)
|
# 配置项(位于插件配置中)
|
||||||
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
|
||||||
|
|
||||||
配置项(位于插件配置中)
|
- `ADMIN_QQ_GROUP`: `list[int]`
|
||||||
ADMIN_QQ_GROUP: list[int]
|
- 允许的管理员 QQ 群 ID 列表
|
||||||
允许的管理员 QQ 群 ID 列表
|
- `ADMIN_QQ_ACCOUNT`: `list[int]`
|
||||||
|
- 允许的管理员 QQ 账号 ID 列表
|
||||||
|
- `ADMIN_DISCORD_CHANNEL`: `list[int]`
|
||||||
|
- 允许的管理员 Discord 频道 ID 列表
|
||||||
|
- `ADMIN_DISCORD_ACCOUNT`: `list[int]`
|
||||||
|
- 允许的管理员 Discord 用户 ID 列表
|
||||||
|
|
||||||
ADMIN_QQ_ACCOUNT: list[int]
|
# 注意事项
|
||||||
允许的管理员 QQ 账号 ID 列表
|
|
||||||
|
|
||||||
ADMIN_DISCORD_CHANNEL: list[int]
|
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
||||||
允许的管理员 Discord 频道 ID 列表
|
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
||||||
|
|
||||||
ADMIN_DISCORD_ACCOUNT: list[int]
|
|
||||||
允许的管理员 Discord 用户 ID 列表
|
|
||||||
|
|
||||||
注意事项
|
|
||||||
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
|
||||||
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
konaph - KonaBot 的 PuzzleHunt 管理工具
|
|
||||||
|
`konaph` - KonaBot 的 PuzzleHunt 管理工具
|
||||||
|
|
||||||
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)
|
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)
|
||||||
|
|||||||
@ -1,59 +1,83 @@
|
|||||||
指令介绍
|
# giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
||||||
giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
|
||||||
|
|
||||||
格式
|
## 格式
|
||||||
giftool [图片] [选项]
|
|
||||||
|
|
||||||
示例
|
```bash
|
||||||
回复一张 GIF 并发送:
|
giftool [图片] [选项]
|
||||||
`giftool --ss 1.5 -t 2.0`
|
```
|
||||||
从 1.5 秒处开始,截取 2 秒长度的片段。
|
|
||||||
|
|
||||||
`giftool [图片] --ss 0:10 -to 0:15`
|
## 示例
|
||||||
截取从 10 秒到 15 秒之间的片段(支持 MM:SS 或 HH:MM:SS 格式)。
|
|
||||||
|
|
||||||
`giftool [图片] --frames:v 10`
|
- **回复一张 GIF 并发送:**
|
||||||
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
|
||||||
|
|
||||||
`giftool [图片] --ss 2 --frames:v 5`
|
```bash
|
||||||
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
giftool --ss 1.5 -t 2.0
|
||||||
|
```
|
||||||
|
|
||||||
参数说明
|
从 1.5 秒处开始,截取 2 秒长度的片段。
|
||||||
图片(必需)
|
|
||||||
- 必须是 GIF 动图。
|
|
||||||
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
|
||||||
|
|
||||||
--ss <时间戳>(可选)
|
- ```bash
|
||||||
- 指定开始时间(单位:秒),可使用以下格式:
|
giftool [图片] --ss 0:10 -to 0:15
|
||||||
• 纯数字(如 `1.5` 表示 1.5 秒)
|
```
|
||||||
• 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
|
||||||
• 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
|
||||||
- 默认从开头开始(0 秒)。
|
|
||||||
|
|
||||||
-t <持续时间>(可选)
|
截取从 10 秒到 15 秒之间的片段(支持 `MM:SS` 或 `HH:MM:SS` 格式)。
|
||||||
- 指定截取的持续时间(单位:秒),格式同 --ss。
|
|
||||||
- 与 --ss 配合使用:截取 [ss, ss + t] 区间。
|
|
||||||
- 不能与 --to 同时使用。
|
|
||||||
|
|
||||||
--to <时间戳>(可选)
|
- ```bash
|
||||||
- 指定结束时间(单位:秒),格式同 --ss。
|
giftool [图片] --frames:v 10
|
||||||
- 与 --ss 配合使用:截取 [ss, to] 区间。
|
```
|
||||||
- 不能与 -t 同时使用。
|
|
||||||
|
|
||||||
--frames:v <帧数>(可选)
|
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
||||||
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
|
||||||
- 帧数必须为正整数(> 0)。
|
|
||||||
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
|
||||||
|
|
||||||
--speed <速度>(可选)
|
- ```bash
|
||||||
- 调整 gif 图的速度。若为负数,则代表倒放
|
giftool [图片] --ss 2 --frames:v 5
|
||||||
|
```
|
||||||
|
|
||||||
使用方式
|
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
||||||
1. 发送指令前,请确保:
|
|
||||||
- 消息中附带一张 GIF 动图,或
|
## 参数说明
|
||||||
- 回复一条包含 GIF 动图的消息后再发送指令。
|
|
||||||
2. 插件会自动:
|
### 图片(必需)
|
||||||
- 解析 GIF 的每一帧及其持续时间(duration)
|
|
||||||
- 根据时间参数转换为帧索引进行裁剪
|
- 必须是 GIF 动图。
|
||||||
- 如指定抽帧,则对裁剪后的片段均匀采样
|
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
||||||
- 生成新的 GIF 并保持原始循环设置(loop=0)
|
|
||||||
|
### `--ss <时间戳>`(可选)
|
||||||
|
|
||||||
|
- 指定开始时间(单位:秒),可使用以下格式:
|
||||||
|
- 纯数字(如 `1.5` 表示 1.5 秒)
|
||||||
|
- 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
||||||
|
- 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
||||||
|
- 默认从开头开始(0 秒)。
|
||||||
|
|
||||||
|
### `-t <持续时间>`(可选)
|
||||||
|
|
||||||
|
- 指定截取的持续时间(单位:秒),格式同 `--ss`。
|
||||||
|
- 与 `--ss` 配合使用:截取 `[ss, ss + t]` 区间。
|
||||||
|
- **不能与 `--to` 同时使用。**
|
||||||
|
|
||||||
|
### `--to <时间戳>`(可选)
|
||||||
|
|
||||||
|
- 指定结束时间(单位:秒),格式同 `--ss`。
|
||||||
|
- 与 `--ss` 配合使用:截取 `[ss, to]` 区间。
|
||||||
|
- **不能与 `-t` 同时使用。**
|
||||||
|
|
||||||
|
### `--frames:v <帧数>`(可选)
|
||||||
|
|
||||||
|
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
||||||
|
- 帧数必须为正整数(> 0)。
|
||||||
|
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
||||||
|
|
||||||
|
### `--speed <速度>`(可选)
|
||||||
|
|
||||||
|
- 调整 GIF 图的速度。若为负数,则代表倒放。
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
1. 发送指令前,请确保:
|
||||||
|
- 消息中附带一张 GIF 动图,**或**
|
||||||
|
- 回复一条包含 GIF 动图的消息后再发送指令。
|
||||||
|
2. 插件会自动:
|
||||||
|
- 解析 GIF 的每一帧及其持续时间(duration)
|
||||||
|
- 根据时间参数转换为帧索引进行裁剪
|
||||||
|
- 如指定抽帧,则对裁剪后的片段均匀采样
|
||||||
|
- 生成新的 GIF 并保持原始循环设置(`loop=0`)
|
||||||
|
|||||||
@ -1,20 +1,33 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
man - 用于展示此方 BOT 使用手册的指令
|
|
||||||
|
|
||||||
格式
|
`man` - 用于展示此方 BOT 使用手册的指令
|
||||||
man 文档类型
|
|
||||||
man [文档类型] <指令>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`man` 查看所有有文档的指令清单
|
|
||||||
`man 3` 列举所有可读文档的库函数清单
|
|
||||||
`man 喵` 查看指令「喵」的使用说明
|
|
||||||
`man 8 out` 查看管理员指令「out」的使用说明
|
|
||||||
|
|
||||||
文档类型
|
```
|
||||||
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为这些种类:
|
man 文档类型
|
||||||
|
man [文档类型] <指令>
|
||||||
|
```
|
||||||
|
|
||||||
- 1 用户态指令,用于日常使用的指令
|
## 示例
|
||||||
- 3 库函数指令,用于 Bot 开发用的函数查询
|
|
||||||
- 7 概念指令,用于概念解释
|
- ``man``
|
||||||
- 8 系统指令,仅管理员可用
|
查看所有有文档的指令清单
|
||||||
|
|
||||||
|
- ``man 3``
|
||||||
|
列举所有可读文档的库函数清单
|
||||||
|
|
||||||
|
- ``man 喵``
|
||||||
|
查看指令「喵」的使用说明
|
||||||
|
|
||||||
|
- ``man 8 out``
|
||||||
|
查看管理员指令「out」的使用说明
|
||||||
|
|
||||||
|
## 文档类型
|
||||||
|
|
||||||
|
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为以下种类:
|
||||||
|
|
||||||
|
- **1** 用户态指令:用于日常使用的指令
|
||||||
|
- **3** 库函数指令:用于 Bot 开发用的函数查询
|
||||||
|
- **7** 概念指令:用于概念解释
|
||||||
|
- **8** 系统指令:仅管理员可用
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
ntfy - 配置使用 ntfy 来更好地为你通知此方 BOT 代办
|
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的代办事项。
|
||||||
|
|
||||||
指令示例
|
## 指令示例
|
||||||
`ntfy 创建`
|
|
||||||
创建一个随机的 ntfy 订阅主题来提醒代办,此方 Bot 将会给你使用指引。你可以前往 https://ntfy.sh/ 官网下载 ntfy APP,或者使用网页版 ntfy。
|
|
||||||
|
|
||||||
`ntfy 创建 kagami-notice`
|
- **`ntfy 创建`**
|
||||||
创建一个名字含有 kagami-notice 的 ntfy 订阅主题
|
创建一个随机的 ntfy 订阅主题来提醒代办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP,或者使用网页版 ntfy。
|
||||||
|
|
||||||
`ntfy 删除`
|
- **`ntfy 创建 kagami-notice`**
|
||||||
清除并不再使用 ntfy 向你通知
|
创建一个名称包含 `kagami-notice` 的 ntfy 订阅主题。
|
||||||
|
|
||||||
另见
|
- **`ntfy 删除`**
|
||||||
提醒我(1) 查询提醒(1) 删除提醒(1)
|
清除配置,不再使用 ntfy 向你发送通知。
|
||||||
|
|
||||||
|
## 另见
|
||||||
|
[`提醒我(1)`](#) [`查询提醒(1)`](#) [`删除提醒(1)`](#)
|
||||||
|
|||||||
@ -1,21 +1,39 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
openssl - 用于生成指定长度的加密安全随机数据
|
|
||||||
|
|
||||||
格式
|
`openssl rand` — 用于生成指定长度的加密安全随机数据。
|
||||||
openssl rand <模式> <字节数>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`openssl rand -hex 16` 生成 16 字节的十六进制随机数
|
|
||||||
`openssl rand -base64 32` 生成 32 字节并以 Base64 编码输出的随机数据
|
|
||||||
|
|
||||||
说明
|
```bash
|
||||||
该指令使用 Python 的 secrets 模块生成加密安全的随机字节,并支持以十六进制(-hex)或 Base64(-base64)格式输出。
|
openssl rand <模式> <字节数>
|
||||||
|
```
|
||||||
|
|
||||||
参数说明
|
## 示例
|
||||||
模式(mode)
|
|
||||||
- -hex :以十六进制字符串形式输出随机数据
|
|
||||||
- -base64 :以 Base64 编码字符串形式输出随机数据
|
|
||||||
|
|
||||||
字节数(num)
|
- ```bash
|
||||||
- 必须为正整数
|
openssl rand -hex 16
|
||||||
- 最大支持 256 字节
|
```
|
||||||
|
生成 16 字节的十六进制随机数。
|
||||||
|
|
||||||
|
- ```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
生成 32 字节并以 Base64 编码输出的随机数据。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
该指令使用 Python 的 `secrets` 模块生成加密安全的随机字节,并支持以以下格式输出:
|
||||||
|
- 十六进制(`-hex`)
|
||||||
|
- Base64 编码(`-base64`)
|
||||||
|
|
||||||
|
## 参数说明
|
||||||
|
|
||||||
|
### 模式(mode)
|
||||||
|
|
||||||
|
- `-hex`:以十六进制字符串形式输出随机数据
|
||||||
|
- `-base64`:以 Base64 编码字符串形式输出随机数据
|
||||||
|
|
||||||
|
### 字节数(num)
|
||||||
|
|
||||||
|
- 必须为正整数
|
||||||
|
- 最大支持 256 字节
|
||||||
|
|||||||
@ -1,47 +1,55 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
shadertool - 使用 SkSL(Skia Shader Language)代码实时渲染并生成 GIF 动画
|
`shadertool` - 使用 SkSL(Skia Shader Language)代码实时渲染并生成 GIF 动画
|
||||||
|
|
||||||
格式
|
## 格式
|
||||||
shadertool [选项] <SkSL 代码>
|
```bash
|
||||||
|
shadertool [选项] <SkSL 代码>
|
||||||
|
```
|
||||||
|
|
||||||
示例
|
## 示例
|
||||||
shadertool """
|
```bash
|
||||||
uniform float u_time;
|
shadertool """
|
||||||
uniform float2 u_resolution;
|
uniform float u_time;
|
||||||
|
uniform float2 u_resolution;
|
||||||
|
|
||||||
half4 main(float2 coord) {
|
half4 main(float2 coord) {
|
||||||
return half4(
|
return half4(
|
||||||
1.0,
|
1.0,
|
||||||
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
||||||
coord.x / u_resolution.x,
|
coord.x / u_resolution.x,
|
||||||
1.0
|
1.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
参数说明
|
## 参数说明
|
||||||
SkSL 代码(必填)
|
|
||||||
- 类型:字符串(建议用英文双引号包裹)
|
|
||||||
- 内容:符合 SkSL 语法的片段着色器代码,必须包含 `void main()` 函数,并为 `sk_FragColor` 赋值。
|
|
||||||
- 注意:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
|
||||||
|
|
||||||
--width <整数>(可选)
|
### SkSL 代码(必填)
|
||||||
- 默认值:320
|
- **类型**:字符串(建议用英文双引号包裹)
|
||||||
- 作用:输出 GIF 的宽度(像素),必须大于 0。
|
- **内容**:符合 SkSL 语法的片段着色器代码,必须包含 `main` 函数,并返回 `half4` 类型的颜色值。
|
||||||
|
- **注意**:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
||||||
|
|
||||||
--height <整数>(可选)
|
### `--width <整数>`(可选)
|
||||||
- 默认值:180
|
- **默认值**:`320`
|
||||||
- 作用:输出 GIF 的高度(像素),必须大于 0。
|
- **作用**:输出 GIF 的宽度(像素),必须大于 0。
|
||||||
|
|
||||||
--duration <浮点数>(可选)
|
### `--height <整数>`(可选)
|
||||||
- 默认值:1.0
|
- **默认值**:`180`
|
||||||
- 作用:动画总时长(秒),必须大于 0。
|
- **作用**:输出 GIF 的高度(像素),必须大于 0。
|
||||||
- 限制:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
|
||||||
|
|
||||||
--fps <浮点数>(可选)
|
### `--duration <浮点数>`(可选)
|
||||||
- 默认值:15.0
|
- **默认值**:`1.0`
|
||||||
- 作用:每秒帧数,控制动画流畅度,必须大于 0。
|
- **作用**:动画总时长(秒),必须大于 0。
|
||||||
- 常见值:10(低配流畅)、15(默认)、24/30(电影/视频级)。
|
- **限制**:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
||||||
|
|
||||||
使用方式
|
### `--fps <浮点数>`(可选)
|
||||||
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
- **默认值**:`15.0`
|
||||||
|
- **作用**:每秒帧数,控制动画流畅度,必须大于 0。
|
||||||
|
- **常见值**:
|
||||||
|
- `10`:低配流畅
|
||||||
|
- `15`:默认
|
||||||
|
- `24` / `30`:电影/视频级流畅度
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
||||||
|
|||||||
@ -1,41 +1,72 @@
|
|||||||
指令介绍
|
# `ytpgif` 指令说明
|
||||||
ytpgif - 生成来回镜像翻转的仿 YTPMV 动图
|
|
||||||
|
|
||||||
格式
|
## 功能简介
|
||||||
ytpgif [倍速]
|
`ytpgif` 用于生成来回镜像翻转的仿 YTPMV(YouTube Poop Music Video)风格动图。
|
||||||
|
|
||||||
示例
|
---
|
||||||
`ytpgif`
|
|
||||||
使用默认倍速(1.0)处理你发送或回复的图片,生成镜像动图。
|
|
||||||
|
|
||||||
`ytpgif 2.5`
|
## 命令格式
|
||||||
以 2.5 倍速处理图片,生成更快节奏的镜像动图。
|
```bash
|
||||||
|
ytpgif [倍速]
|
||||||
|
```
|
||||||
|
|
||||||
回复一张图片并发送 `ytpgif 0.5`
|
---
|
||||||
以慢速(0.5 倍)生成镜像动图。
|
|
||||||
|
|
||||||
参数说明
|
## 使用示例
|
||||||
倍速(可选)
|
|
||||||
- 类型:浮点数
|
|
||||||
- 默认值:1.0
|
|
||||||
- 有效范围:0.1 ~ 20.0
|
|
||||||
- 作用:
|
|
||||||
• 对于静态图:控制镜像切换的快慢(值越大,切换越快)。
|
|
||||||
• 对于动图:控制截取原始动图正向和反向片段的时长(值越大,截取的片段越长)。
|
|
||||||
|
|
||||||
使用方式
|
- **默认倍速**
|
||||||
发送指令前,请确保:
|
```bash
|
||||||
- 直接在消息中附带一张图片,或
|
ytpgif
|
||||||
- 回复一条包含图片的消息后再发送指令。
|
```
|
||||||
|
使用默认倍速(1.0)处理你发送或回复的图片,生成镜像动图。
|
||||||
|
|
||||||
插件会自动:
|
- **指定倍速(较快)**
|
||||||
- 下载并识别图片(支持静态图和 GIF 动图)
|
```bash
|
||||||
- 自动缩放至最大边长不超过 256 像素(保持宽高比)
|
ytpgif 2.5
|
||||||
- 静态图 → 生成“原图↔镜像”循环动图
|
```
|
||||||
- 动图 → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图
|
以 2.5 倍速处理图片,生成节奏更快的镜像动图。
|
||||||
- 保留透明通道(如原图含透明),否则转为 RGB 避免颜色异常
|
|
||||||
|
|
||||||
注意事项
|
- **指定倍速(较慢)**
|
||||||
- 图片过大、格式损坏或网络问题可能导致处理失败。
|
回复一张图片并发送:
|
||||||
- 动图帧数过多或单帧过短可能无法生成有效输出。
|
```bash
|
||||||
- 输出 GIF 最大单段帧数限制为 500 帧,以防资源耗尽。
|
ytpgif 0.5
|
||||||
|
```
|
||||||
|
以 0.5 倍速生成慢节奏的镜像动图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参数说明
|
||||||
|
|
||||||
|
### `倍速`(可选)
|
||||||
|
- **类型**:浮点数
|
||||||
|
- **默认值**:`1.0`
|
||||||
|
- **有效范围**:`0.1 ~ 20.0`
|
||||||
|
|
||||||
|
#### 作用:
|
||||||
|
- **静态图**:控制“原图 ↔ 镜像”切换的速度(值越大,切换越快)。
|
||||||
|
- **GIF 动图**:控制截取原始动图正向与反向片段的时长(值越大,截取的片段越长)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
在发送指令前,请确保满足以下任一条件:
|
||||||
|
- 在消息中**直接附带一张图片**,或
|
||||||
|
- **回复一条包含图片的消息**后再发送指令。
|
||||||
|
|
||||||
|
插件将自动执行以下操作:
|
||||||
|
1. 下载并识别图片(支持静态图和 GIF 动图)。
|
||||||
|
2. 自动缩放图像,**最大边长不超过 256 像素**(保持宽高比)。
|
||||||
|
3. 根据图片类型处理:
|
||||||
|
- **静态图** → 生成“原图 ↔ 镜像”循环动图。
|
||||||
|
- **GIF 动图** → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图。
|
||||||
|
4. **保留透明通道**(若原图含透明),否则转为 RGB 模式以避免颜色异常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
⚠️ 以下情况可能导致处理失败或效果不佳:
|
||||||
|
- 图片过大、格式损坏或网络问题;
|
||||||
|
- 动图帧数过多或单帧持续时间过短;
|
||||||
|
- 输出 GIF 单段帧数超过 **500 帧**(系统将自动限制以防资源耗尽)。
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
删除提醒 - 删除在`查询提醒(1)`中查到的提醒
|
**删除提醒** - 删除在 [`查询提醒(1)`](查询提醒(1)) 中查到的提醒
|
||||||
|
|
||||||
指令示例
|
## 指令示例
|
||||||
`删除提醒 1` 在查询提醒后,删除编号为 1 的提醒
|
`删除提醒 1`
|
||||||
|
在查询提醒后,删除编号为 1 的提醒
|
||||||
|
|
||||||
另见
|
## 另见
|
||||||
提醒我(1) 查询提醒(1) ntfy(1)
|
[`提醒我(1)`](提醒我(1)) [`查询提醒(1)`](查询提醒(1)) [`ntfy(1)`](ntfy(1))
|
||||||
|
|||||||
@ -1,20 +1,24 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
卵总展示 - 让卵总举起你的图片
|
|
||||||
|
|
||||||
格式
|
**卵总展示** - 让卵总举起你的图片
|
||||||
<引用图片> 卵总展示 [选项]
|
|
||||||
卵总展示 [选项] <图片>
|
|
||||||
|
|
||||||
选项
|
## 格式
|
||||||
`--whiteness <number>` 白度
|
|
||||||
将原图进行指数变换,以调整它的白的程度,默认为 0.0
|
|
||||||
|
|
||||||
`--black-level <number>` 黑色等级
|
```
|
||||||
将原图减淡,数值越大越淡,范围 0.0-1.0,默认 0.2
|
<引用图片> 卵总展示 [选项]
|
||||||
|
卵总展示 [选项] <图片>
|
||||||
|
```
|
||||||
|
|
||||||
`--opacity <number>` 不透明度
|
## 选项
|
||||||
将你的图片叠放在图片上的不透明度,默认为 0.8
|
|
||||||
|
|
||||||
`--saturation <number>` 饱和度
|
- `--whiteness <number>` **白度**
|
||||||
调整原图的饱和度,应该要大于 0.0,默认为 0.85
|
将原图进行指数变换,以调整它的白的程度,默认为 `0.0`。
|
||||||
|
|
||||||
|
- `--black-level <number>` **黑色等级**
|
||||||
|
将原图减淡,数值越大越淡,范围 `0.0–1.0`,默认为 `0.2`。
|
||||||
|
|
||||||
|
- `--opacity <number>` **不透明度**
|
||||||
|
将你的图片叠放在图片上的不透明度,默认为 `0.8`。
|
||||||
|
|
||||||
|
- `--saturation <number>` **饱和度**
|
||||||
|
调整原图的饱和度,应大于 `0.0`,默认为 `0.85`。
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
指令介绍
|
### 指令介绍
|
||||||
发起投票 - 发起一个投票
|
**发起投票** - 发起一个投票
|
||||||
|
|
||||||
格式
|
### 格式
|
||||||
发起投票 <投票标题> <选项1> <选项2> ...
|
```
|
||||||
|
发起投票 <投票标题> <选项1> <选项2> ...
|
||||||
|
```
|
||||||
|
|
||||||
示例
|
### 示例
|
||||||
`发起投票 这是一个投票 A B C` 发起标题为“这是一个投票”,选项为“A”、“B”、“C”的投票
|
`发起投票 这是一个投票 A B C`
|
||||||
|
发起标题为“这是一个投票”,选项为“A”、“B”、“C”的投票。
|
||||||
|
|
||||||
说明
|
### 说明
|
||||||
投票各个选项之间用空格分隔,选项数量为2-15项。投票的默认有效期为24小时。
|
- 投票的各个选项之间用空格分隔。
|
||||||
|
- 选项数量必须为 **2 到 15 项**。
|
||||||
|
- 投票的默认有效期为 **24 小时**。
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
喵 - 你发喵,此方就会回复喵
|
|
||||||
|
喵 - 你发喵,此方就会回复喵
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
投票 - 参与已发起的投票
|
**投票** - 参与已发起的投票
|
||||||
|
|
||||||
格式
|
## 格式
|
||||||
投票 <投票ID/标题> <选项文本>
|
```
|
||||||
|
投票 <投票ID/标题> <选项文本>
|
||||||
|
```
|
||||||
|
|
||||||
示例
|
## 示例
|
||||||
`投票 1 A` 在ID为1的投票中,投给“A”
|
- `投票 1 A`
|
||||||
`投票 这是一个投票 B` 在标题为“这是一个投票”的投票中,投给“B”
|
在 ID 为 1 的投票中,投给 “A”
|
||||||
|
- `投票 这是一个投票 B`
|
||||||
|
在标题为 “这是一个投票” 的投票中,投给 “B”
|
||||||
|
|
||||||
说明
|
## 说明
|
||||||
目前不支持单人多投,每个人只能投一项。
|
目前不支持单人多投,每个人只能投一项。
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
提醒我 - 在指定的时间提醒人事项的工具
|
|
||||||
|
|
||||||
使用示例
|
**提醒我** - 在指定的时间提醒人事项的工具
|
||||||
`下午五点提醒我吃饭`
|
|
||||||
创建一个下午五点的提醒,提醒你吃饭
|
|
||||||
|
|
||||||
`两分钟后提醒我睡觉`
|
## 使用示例
|
||||||
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
|
|
||||||
|
|
||||||
`2026年4月25日20点整提醒我生日快乐`
|
- `下午五点提醒我吃饭`
|
||||||
创建一个指定日期和时间的提醒
|
创建一个下午五点的提醒,提醒你吃饭
|
||||||
|
|
||||||
另见
|
- `两分钟后提醒我睡觉`
|
||||||
查询提醒(1) 删除提醒(1) ntfy(1)
|
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
|
||||||
|
|
||||||
|
- `2026年4月25日20点整提醒我生日快乐`
|
||||||
|
创建一个指定日期和时间的提醒
|
||||||
|
|
||||||
|
## 另见
|
||||||
|
|
||||||
|
[`查询提醒(1)`](查询提醒) [`删除提醒(1)`](删除提醒) [`ntfy(1)`](ntfy)
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
摇数字 - 生成一个随机数字并发送
|
|
||||||
|
|
||||||
示例
|
**摇数字** - 生成一个随机数字并发送
|
||||||
`摇数字` 随机生成一个 1-6 的数字
|
|
||||||
|
|
||||||
该指令不接受任何参数,直接调用即可。
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
摇数字
|
||||||
|
```
|
||||||
|
|
||||||
|
随机生成一个 1-6 的数字。
|
||||||
|
|
||||||
|
> 该指令不接受任何参数,直接调用即可。
|
||||||
|
|||||||
@ -1,22 +1,33 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
摇骰子 - 用于生成随机数并以骰子图像形式展示的指令
|
|
||||||
|
|
||||||
格式
|
**摇骰子** - 用于生成随机数并以骰子图像形式展示的指令
|
||||||
摇骰子 [最小值] [最大值]
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`摇骰子` 随机生成一个 1-6 的数字,并显示对应的骰子图像
|
|
||||||
`摇骰子 10` 生成 1 到 10 之间的随机整数
|
|
||||||
`摇骰子 0.5` 生成 0 到 0.5 之间的随机小数
|
|
||||||
`摇骰子 -5 5` 生成 -5 到 5 之间的随机数
|
|
||||||
|
|
||||||
说明
|
```
|
||||||
该指令支持以下几种调用方式:
|
摇骰子 [最小值] [最大值]
|
||||||
- 不带参数:使用默认范围生成随机数
|
```
|
||||||
- 仅指定一个参数 f1:
|
|
||||||
- 若 f1 > 1,则生成 [1, f1] 范围内的随机数
|
## 示例
|
||||||
- 若 0 < f1 ≤ 1,则生成 [0, f1] 范围内的随机数
|
|
||||||
- 若 f1 ≤ 0,则生成 [f1, 0] 范围内的随机数
|
- `摇骰子`
|
||||||
- 指定两个参数 f1 和 f2:生成 [f1, f2] 范围内的随机数(顺序无关,内部会自动处理大小)
|
随机生成一个 1–6 的数字,并显示对应的骰子图像
|
||||||
|
- `摇骰子 10`
|
||||||
|
生成 1 到 10 之间的随机整数
|
||||||
|
- `摇骰子 0.5`
|
||||||
|
生成 0 到 0.5 之间的随机小数
|
||||||
|
- `摇骰子 -5 5`
|
||||||
|
生成 -5 到 5 之间的随机数
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
该指令支持以下几种调用方式:
|
||||||
|
|
||||||
|
- **不带参数**:使用默认范围(1–6)生成随机数
|
||||||
|
- **仅指定一个参数 `f1`**:
|
||||||
|
- 若 `f1 > 1`,则生成 `[1, f1]` 范围内的随机数
|
||||||
|
- 若 `0 < f1 ≤ 1`,则生成 `[0, f1]` 范围内的随机数
|
||||||
|
- 若 `f1 ≤ 0`,则生成 `[f1, 0]` 范围内的随机数
|
||||||
|
- **指定两个参数 `f1` 和 `f2`**:生成 `[f1, f2]` 范围内的随机数(顺序无关,内部会自动处理大小)
|
||||||
|
|
||||||
返回结果将以骰子样式的图像形式展示生成的随机数值。
|
返回结果将以骰子样式的图像形式展示生成的随机数值。
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
查看投票 - 查看已发起的投票
|
|
||||||
|
|
||||||
格式
|
**查看投票** - 查看已发起的投票
|
||||||
查看投票 <投票ID或标题>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`查看投票 1` 查看ID为1的投票
|
|
||||||
`查看投票 这是一个投票` 查看标题为“这是一个投票”的投票
|
|
||||||
|
|
||||||
说明
|
```
|
||||||
投票在进行时,使用此命令可以看到投票的各个选项;投票结束后,则可以看到各项的票数。
|
查看投票 <投票ID或标题>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- `查看投票 1`
|
||||||
|
查看 ID 为 1 的投票
|
||||||
|
|
||||||
|
- `查看投票 这是一个投票`
|
||||||
|
查看标题为“这是一个投票”的投票
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 投票进行中时,使用此命令可查看投票的各个选项;
|
||||||
|
- 投票结束后,可查看各选项的最终票数。
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
查询提醒 - 查询已经创建的提醒
|
**查询提醒** - 查询已经创建的提醒
|
||||||
|
|
||||||
指令格式
|
## 指令格式
|
||||||
`查询提醒` 查询提醒
|
- `查询提醒`:查询提醒
|
||||||
`查询提醒 2` 查询第二页提醒
|
- `查询提醒 2`:查询第二页提醒
|
||||||
|
|
||||||
另见
|
## 另见
|
||||||
提醒我(1) 删除提醒(1) ntfy(1)
|
[提醒我(1)]() [删除提醒(1)]() [ntfy(1)]()
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
生成二维码 - 将文本内容转换为二维码
|
|
||||||
|
|
||||||
格式
|
**生成二维码** - 将文本内容转换为二维码
|
||||||
生成二维码 <文本内容>
|
|
||||||
|
|
||||||
示例
|
### 格式
|
||||||
`生成二维码 嗨嗨嗨` 生成扫描结果为“嗨嗨嗨”的二维码图片
|
|
||||||
|
```
|
||||||
|
生成二维码 <文本内容>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
生成二维码 嗨嗨嗨
|
||||||
|
```
|
||||||
|
|
||||||
|
生成扫描结果为“嗨嗨嗨”的二维码图片
|
||||||
|
|||||||
30
konabot/docs/user/订阅.txt
Normal file
30
konabot/docs/user/订阅.txt
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# 指令介绍
|
||||||
|
|
||||||
|
**订阅** - 收听此方 BOT 的自动消息发送。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 格式
|
||||||
|
|
||||||
|
- `订阅 <频道名称>`
|
||||||
|
- `取消订阅 <频道名称>`
|
||||||
|
- `查询订阅 [页码]`
|
||||||
|
- `可用订阅 [页码]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- **`订阅 此方谜题`**
|
||||||
|
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
|
||||||
|
- 如果你是私聊,则能够每天发送此方谜题到你的私聊;
|
||||||
|
- 如果在群聊中使用该指令,则会每天发送题目到这个群里面。
|
||||||
|
|
||||||
|
- **`取消订阅 此方谜题`**
|
||||||
|
取消订阅「此方谜题」频道。
|
||||||
|
|
||||||
|
- **`查询订阅`**
|
||||||
|
查询当前聊天上下文订阅的所有频道。
|
||||||
|
|
||||||
|
- **`可用订阅 2`**
|
||||||
|
查询所有可用的订阅的第二页。
|
||||||
@ -1,13 +1,20 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
雷达回波 - 用于获取指定地区的天气雷达回波图像
|
|
||||||
|
|
||||||
格式
|
**雷达回波** - 用于获取指定地区的天气雷达回波图像。
|
||||||
雷达回波 <地区>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`雷达回波 华南` 获取华南地区的天气雷达回波图
|
|
||||||
`雷达回波 全国` 获取全国的天气雷达回波图
|
|
||||||
|
|
||||||
说明
|
```
|
||||||
该指令通过查询中国气象局 https://www.nmc.cn/publish/radar/chinaall.html ,获取指定地区的实时天气雷达回波图像。
|
雷达回波 <地区>
|
||||||
支持的地区有:全国 华北 东北 华东 华中 华南 西南 西北。
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- `雷达回波 华南`:获取华南地区的天气雷达回波图
|
||||||
|
- `雷达回波 全国`:获取全国的天气雷达回波图
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
该指令通过查询中国气象局 [https://www.nmc.cn/publish/radar/chinaall.html](https://www.nmc.cn/publish/radar/chinaall.html),获取指定地区的实时天气雷达回波图像。
|
||||||
|
|
||||||
|
支持的地区有:**全国**、**华北**、**东北**、**华东**、**华中**、**华南**、**西南**、**西北**。
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
黑白 - 将图片经过一个黑白滤镜的处理
|
|
||||||
|
|
||||||
示例
|
**黑白** - 将图片经过一个黑白滤镜的处理
|
||||||
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
||||||
|
|||||||
@ -1,22 +1,29 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
import cv2
|
import cv2
|
||||||
|
import nonebot
|
||||||
from nonebot.adapters import Event as BaseEvent
|
from nonebot.adapters import Event as BaseEvent
|
||||||
from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent
|
from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent
|
||||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||||
from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna
|
from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.path import ASSETS_PATH
|
from konabot.common.path import ASSETS_PATH
|
||||||
from konabot.common.web_render import WebRenderer
|
from konabot.common.web_render import WebRenderer
|
||||||
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
|
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
|
||||||
|
from pathlib import Path
|
||||||
import random
|
import random
|
||||||
import math
|
import math
|
||||||
|
|
||||||
def get_ac(id: str) -> AirConditioner:
|
ROOT_PATH = Path(__file__).resolve().parent
|
||||||
ac = AirConditioner.air_conditioners.get(id)
|
|
||||||
|
# 创建全局数据库管理器实例
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
|
async def get_ac(id: str) -> AirConditioner:
|
||||||
|
ac = await AirConditioner.get_ac(id)
|
||||||
if ac is None:
|
if ac is None:
|
||||||
ac = AirConditioner(id)
|
ac = AirConditioner(id)
|
||||||
return ac
|
return ac
|
||||||
@ -43,14 +50,32 @@ async def send_ac_image(event: type[AlconnaMatcher], ac: AirConditioner):
|
|||||||
ac_image = await generate_ac_image(ac)
|
ac_image = await generate_ac_image(ac)
|
||||||
await event.send(await UniMessage().image(raw=ac_image).export())
|
await event.send(await UniMessage().image(raw=ac_image).export())
|
||||||
|
|
||||||
|
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|
||||||
|
@driver.on_startup
|
||||||
|
async def register_startup_hook():
|
||||||
|
"""注册启动时需要执行的函数"""
|
||||||
|
# 初始化数据库表
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
Path(__file__).resolve().parent / "sql" / "create_table.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
@driver.on_shutdown
|
||||||
|
async def register_shutdown_hook():
|
||||||
|
"""注册关闭时需要执行的函数"""
|
||||||
|
# 关闭所有数据库连接
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
evt = on_alconna(Alconna(
|
evt = on_alconna(Alconna(
|
||||||
"群空调"
|
"群空调"
|
||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
ac = await get_ac(id)
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
|
|
||||||
evt = on_alconna(Alconna(
|
evt = on_alconna(Alconna(
|
||||||
@ -58,10 +83,10 @@ evt = on_alconna(Alconna(
|
|||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
ac = await get_ac(id)
|
||||||
ac.on = True
|
await ac.update_ac(state=True)
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
|
|
||||||
evt = on_alconna(Alconna(
|
evt = on_alconna(Alconna(
|
||||||
@ -69,10 +94,10 @@ evt = on_alconna(Alconna(
|
|||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
ac = await get_ac(id)
|
||||||
ac.on = False
|
await ac.update_ac(state=False)
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
|
|
||||||
evt = on_alconna(Alconna(
|
evt = on_alconna(Alconna(
|
||||||
@ -81,15 +106,17 @@ evt = on_alconna(Alconna(
|
|||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
async def _(target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||||
|
if temp is None:
|
||||||
|
temp = 1
|
||||||
if temp <= 0:
|
if temp <= 0:
|
||||||
return
|
return
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
ac = await get_ac(id)
|
||||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
return
|
return
|
||||||
ac.temperature += temp
|
await ac.update_ac(temperature_delta=temp)
|
||||||
if ac.temperature > 40:
|
if ac.temperature > 40:
|
||||||
# 根据温度随机出是否爆炸,40度开始,呈指数增长
|
# 根据温度随机出是否爆炸,40度开始,呈指数增长
|
||||||
possibility = -math.e ** ((40-ac.temperature) / 50) + 1
|
possibility = -math.e ** ((40-ac.temperature) / 50) + 1
|
||||||
@ -103,7 +130,7 @@ async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[in
|
|||||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=35, disposal=2)
|
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=35, disposal=2)
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
await evt.send(await UniMessage().image(raw=output).export())
|
await evt.send(await UniMessage().image(raw=output).export())
|
||||||
ac.broke_ac(CrashType.BURNT)
|
await ac.broke_ac(CrashType.BURNT)
|
||||||
await evt.send("太热啦,空调炸了!")
|
await evt.send("太热啦,空调炸了!")
|
||||||
return
|
return
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
@ -114,20 +141,22 @@ evt = on_alconna(Alconna(
|
|||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
async def _(target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||||
|
if temp is None:
|
||||||
|
temp = 1
|
||||||
if temp <= 0:
|
if temp <= 0:
|
||||||
return
|
return
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
ac = await get_ac(id)
|
||||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
return
|
return
|
||||||
ac.temperature -= temp
|
await ac.update_ac(temperature_delta=-temp)
|
||||||
if ac.temperature < 0:
|
if ac.temperature < 0:
|
||||||
# 根据温度随机出是否冻结,0度开始,呈指数增长
|
# 根据温度随机出是否冻结,0度开始,呈指数增长
|
||||||
possibility = -math.e ** (ac.temperature / 50) + 1
|
possibility = -math.e ** (ac.temperature / 50) + 1
|
||||||
if random.random() < possibility:
|
if random.random() < possibility:
|
||||||
ac.broke_ac(CrashType.FROZEN)
|
await ac.broke_ac(CrashType.FROZEN)
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
|
|
||||||
evt = on_alconna(Alconna(
|
evt = on_alconna(Alconna(
|
||||||
@ -135,21 +164,34 @@ evt = on_alconna(Alconna(
|
|||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
ac = await get_ac(id)
|
||||||
ac.change_ac()
|
await ac.change_ac()
|
||||||
await send_ac_image(evt, ac)
|
await send_ac_image(evt, ac)
|
||||||
|
|
||||||
|
async def query_number_ranking(id: str) -> tuple[int, int]:
|
||||||
|
result = await db_manager.query_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "query_crash_and_rank.sql",
|
||||||
|
(id,id)
|
||||||
|
)
|
||||||
|
if len(result) == 0:
|
||||||
|
return 0, 0
|
||||||
|
else:
|
||||||
|
# 将字典转换为值的元组
|
||||||
|
values = list(result[0].values())
|
||||||
|
return values[0], values[1]
|
||||||
|
|
||||||
evt = on_alconna(Alconna(
|
evt = on_alconna(Alconna(
|
||||||
"空调炸炸排行榜",
|
"空调炸炸排行榜",
|
||||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||||
|
|
||||||
@evt.handle()
|
@evt.handle()
|
||||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
id = target.channel_id
|
id = target.channel_id
|
||||||
ac = get_ac(id)
|
# ac = get_ac(id)
|
||||||
number, ranking = ac.get_crashes_and_ranking()
|
# number, ranking = ac.get_crashes_and_ranking()
|
||||||
|
number, ranking = await query_number_ranking(id)
|
||||||
params = {
|
params = {
|
||||||
"number": number,
|
"number": number,
|
||||||
"ranking": ranking
|
"ranking": ranking
|
||||||
@ -159,4 +201,4 @@ async def _(event: BaseEvent, target: DepLongTaskTarget):
|
|||||||
target=".box",
|
target=".box",
|
||||||
params=params
|
params=params
|
||||||
)
|
)
|
||||||
await evt.send(await UniMessage().image(raw=image).export())
|
await evt.send(await UniMessage().image(raw=image).export())
|
||||||
|
|||||||
@ -1,20 +1,74 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
from konabot.common.path import ASSETS_PATH, FONTS_PATH
|
from konabot.common.path import ASSETS_PATH, FONTS_PATH
|
||||||
from konabot.common.path import DATA_PATH
|
from konabot.common.path import DATA_PATH
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
ROOT_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# 创建全局数据库管理器实例
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
class CrashType(Enum):
|
class CrashType(Enum):
|
||||||
BURNT = 0
|
BURNT = 0
|
||||||
FROZEN = 1
|
FROZEN = 1
|
||||||
|
|
||||||
class AirConditioner:
|
class AirConditioner:
|
||||||
air_conditioners: dict[str, "AirConditioner"] = {}
|
@classmethod
|
||||||
|
async def get_ac(cls, id: str) -> 'AirConditioner':
|
||||||
|
result = await db_manager.query_by_sql_file(ROOT_PATH / "sql" / "query_ac.sql", (id,))
|
||||||
|
if len(result) == 0:
|
||||||
|
ac = await cls.create_ac(id)
|
||||||
|
return ac
|
||||||
|
ac_data = result[0]
|
||||||
|
ac = AirConditioner(id)
|
||||||
|
ac.on = bool(ac_data["on"])
|
||||||
|
ac.temperature = float(ac_data["temperature"])
|
||||||
|
ac.burnt = bool(ac_data["burnt"])
|
||||||
|
ac.frozen = bool(ac_data["frozen"])
|
||||||
|
return ac
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_ac(cls, id: str) -> 'AirConditioner':
|
||||||
|
ac = AirConditioner(id)
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_ac.sql",
|
||||||
|
(id, ac.on, ac.temperature, ac.burnt, ac.frozen)
|
||||||
|
)
|
||||||
|
return ac
|
||||||
|
|
||||||
|
async def update_ac(self, state: bool = None, temperature_delta: float = None, burnt: bool = None, frozen: bool = None) -> 'AirConditioner':
|
||||||
|
if state is not None:
|
||||||
|
self.on = state
|
||||||
|
if temperature_delta is not None:
|
||||||
|
self.temperature += temperature_delta
|
||||||
|
if burnt is not None:
|
||||||
|
self.burnt = burnt
|
||||||
|
if frozen is not None:
|
||||||
|
self.frozen = frozen
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "update_ac.sql",
|
||||||
|
(self.on, self.temperature, self.burnt, self.frozen, self.id)
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def change_ac(self) -> 'AirConditioner':
|
||||||
|
self.on = False
|
||||||
|
self.temperature = 24
|
||||||
|
self.burnt = False
|
||||||
|
self.frozen = False
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "update_ac.sql",
|
||||||
|
(self.on, self.temperature, self.burnt, self.frozen, self.id)
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
def __init__(self, id: str) -> None:
|
def __init__(self, id: str) -> None:
|
||||||
self.id = id
|
self.id = id
|
||||||
@ -22,45 +76,40 @@ class AirConditioner:
|
|||||||
self.temperature = 24 # 默认温度
|
self.temperature = 24 # 默认温度
|
||||||
self.burnt = False
|
self.burnt = False
|
||||||
self.frozen = False
|
self.frozen = False
|
||||||
AirConditioner.air_conditioners[id] = self
|
|
||||||
|
|
||||||
def change_ac(self):
|
async def broke_ac(self, crash_type: CrashType):
|
||||||
self.burnt = False
|
|
||||||
self.frozen = False
|
|
||||||
self.on = False
|
|
||||||
self.temperature = 24 # 重置为默认温度
|
|
||||||
|
|
||||||
def broke_ac(self, crash_type: CrashType):
|
|
||||||
'''
|
'''
|
||||||
让空调坏掉,并保存数据
|
让空调坏掉
|
||||||
|
|
||||||
:param crash_type: CrashType 枚举,表示空调坏掉的类型
|
:param crash_type: CrashType 枚举,表示空调坏掉的类型
|
||||||
'''
|
'''
|
||||||
match crash_type:
|
match crash_type:
|
||||||
case CrashType.BURNT:
|
case CrashType.BURNT:
|
||||||
self.burnt = True
|
await self.update_ac(burnt=True)
|
||||||
case CrashType.FROZEN:
|
case CrashType.FROZEN:
|
||||||
self.frozen = True
|
await self.update_ac(frozen=True)
|
||||||
self.save_crash_data(crash_type)
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_crash.sql",
|
||||||
|
(self.id, crash_type.value)
|
||||||
|
)
|
||||||
|
|
||||||
def save_crash_data(self, crash_type: CrashType):
|
# def save_crash_data(self, crash_type: CrashType):
|
||||||
'''
|
# '''
|
||||||
如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
|
# 如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
|
||||||
'''
|
# '''
|
||||||
data_file = DATA_PATH / "ac_crash_data.json"
|
# data_file = DATA_PATH / "ac_crash_data.json"
|
||||||
crash_data = {}
|
# crash_data = {}
|
||||||
if data_file.exists():
|
# if data_file.exists():
|
||||||
with open(data_file, "r", encoding="utf-8") as f:
|
# with open(data_file, "r", encoding="utf-8") as f:
|
||||||
crash_data = json.load(f)
|
# crash_data = json.load(f)
|
||||||
if self.id not in crash_data:
|
# if self.id not in crash_data:
|
||||||
crash_data[self.id] = {"burnt": 0, "frozen": 0}
|
# crash_data[self.id] = {"burnt": 0, "frozen": 0}
|
||||||
match crash_type:
|
# match crash_type:
|
||||||
case CrashType.BURNT:
|
# case CrashType.BURNT:
|
||||||
crash_data[self.id]["burnt"] += 1
|
# crash_data[self.id]["burnt"] += 1
|
||||||
case CrashType.FROZEN:
|
# case CrashType.FROZEN:
|
||||||
crash_data[self.id]["frozen"] += 1
|
# crash_data[self.id]["frozen"] += 1
|
||||||
with open(data_file, "w", encoding="utf-8") as f:
|
# with open(data_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(crash_data, f, ensure_ascii=False, indent=4)
|
# json.dump(crash_data, f, ensure_ascii=False, indent=4)
|
||||||
|
|
||||||
def get_crashes_and_ranking(self) -> tuple[int, int]:
|
def get_crashes_and_ranking(self) -> tuple[int, int]:
|
||||||
'''
|
'''
|
||||||
|
|||||||
15
konabot/plugins/air_conditioner/sql/create_table.sql
Normal file
15
konabot/plugins/air_conditioner/sql/create_table.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- 创建所有表
|
||||||
|
CREATE TABLE IF NOT EXISTS air_conditioner (
|
||||||
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
|
"on" BOOLEAN NOT NULL,
|
||||||
|
temperature REAL NOT NULL,
|
||||||
|
burnt BOOLEAN NOT NULL,
|
||||||
|
frozen BOOLEAN NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS air_conditioner_crash_log (
|
||||||
|
id VARCHAR(128) NOT NULL,
|
||||||
|
crash_type INT NOT NULL,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES air_conditioner(id)
|
||||||
|
);
|
||||||
3
konabot/plugins/air_conditioner/sql/insert_ac.sql
Normal file
3
konabot/plugins/air_conditioner/sql/insert_ac.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 插入一台新空调
|
||||||
|
INSERT INTO air_conditioner (id, "on", temperature, burnt, frozen)
|
||||||
|
VALUES (?, ?, ?, ?, ?);
|
||||||
3
konabot/plugins/air_conditioner/sql/insert_crash.sql
Normal file
3
konabot/plugins/air_conditioner/sql/insert_crash.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 插入一条空调爆炸记录
|
||||||
|
INSERT INTO air_conditioner_crash_log (id, crash_type, timestamp)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP);
|
||||||
4
konabot/plugins/air_conditioner/sql/query_ac.sql
Normal file
4
konabot/plugins/air_conditioner/sql/query_ac.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- 查询空调状态,如果没有就插入一条新的记录
|
||||||
|
SELECT *
|
||||||
|
FROM air_conditioner
|
||||||
|
WHERE id = ?;
|
||||||
23
konabot/plugins/air_conditioner/sql/query_crash_and_rank.sql
Normal file
23
konabot/plugins/air_conditioner/sql/query_crash_and_rank.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- 从 air_conditioner_crash_log 表中获取指定 id 损坏的次数以及损坏次数的排名
|
||||||
|
SELECT crash_count, crash_rank
|
||||||
|
FROM (
|
||||||
|
SELECT id,
|
||||||
|
COUNT(*) AS crash_count,
|
||||||
|
RANK() OVER (ORDER BY COUNT(*) DESC) AS crash_rank
|
||||||
|
FROM air_conditioner_crash_log
|
||||||
|
GROUP BY id
|
||||||
|
) AS ranked_data
|
||||||
|
WHERE id = ?
|
||||||
|
-- 如果该 id 没有损坏记录,则返回 0 次损坏和对应的最后一名
|
||||||
|
UNION
|
||||||
|
SELECT 0 AS crash_count,
|
||||||
|
(SELECT COUNT(DISTINCT id) + 1 FROM air_conditioner_crash_log) AS crash_rank
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT id
|
||||||
|
FROM air_conditioner_crash_log
|
||||||
|
) AS ranked_data
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM air_conditioner_crash_log
|
||||||
|
WHERE id = ?
|
||||||
|
);
|
||||||
4
konabot/plugins/air_conditioner/sql/update_ac.sql
Normal file
4
konabot/plugins/air_conditioner/sql/update_ac.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- 更新空调状态
|
||||||
|
UPDATE air_conditioner
|
||||||
|
SET "on" = ?, temperature = ?, burnt = ?, frozen = ?
|
||||||
|
WHERE id = ?;
|
||||||
@ -8,6 +8,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import on_message
|
from nonebot import on_message
|
||||||
|
import nonebot
|
||||||
from nonebot.adapters import Event as BaseEvent
|
from nonebot.adapters import Event as BaseEvent
|
||||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||||
from nonebot_plugin_alconna import (
|
from nonebot_plugin_alconna import (
|
||||||
@ -18,13 +19,22 @@ from nonebot_plugin_alconna import (
|
|||||||
on_alconna,
|
on_alconna,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.path import ASSETS_PATH
|
from konabot.common.path import ASSETS_PATH
|
||||||
|
|
||||||
|
from konabot.common.llm import get_llm
|
||||||
|
|
||||||
|
ROOT_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
|
||||||
|
|
||||||
DATA_FILE_PATH = (
|
DATA_FILE_PATH = (
|
||||||
Path(__file__).parent.parent.parent.parent / "data" / "idiom_banned.json"
|
DATA_DIR / "idiom_banned.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 创建全局数据库管理器实例
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
|
||||||
def load_banned_ids() -> list[str]:
|
def load_banned_ids() -> list[str]:
|
||||||
if not DATA_FILE_PATH.exists():
|
if not DATA_FILE_PATH.exists():
|
||||||
@ -55,6 +65,21 @@ def remove_banned_id(group_id: str):
|
|||||||
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4), "utf-8")
|
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 IdiomGame.init_lexicon()
|
||||||
|
|
||||||
|
@driver.on_shutdown
|
||||||
|
async def register_shutdown_hook():
|
||||||
|
"""注册关闭时需要执行的函数"""
|
||||||
|
# 关闭所有数据库连接
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
|
|
||||||
class TryStartState(Enum):
|
class TryStartState(Enum):
|
||||||
STARTED = 0
|
STARTED = 0
|
||||||
ALREADY_PLAYING = 1
|
ALREADY_PLAYING = 1
|
||||||
@ -75,13 +100,35 @@ class TryVerifyState(Enum):
|
|||||||
BUT_NO_NEXT = 5
|
BUT_NO_NEXT = 5
|
||||||
GAME_END = 6
|
GAME_END = 6
|
||||||
|
|
||||||
|
class IdiomGameLLM:
|
||||||
|
@classmethod
|
||||||
|
async def verify_idiom_with_llm(cls, idiom: str) -> bool:
|
||||||
|
if len(idiom) != 4:
|
||||||
|
return False
|
||||||
|
llm = get_llm()
|
||||||
|
system_prompt = "请判断用户的输入是否为一个合理的成语,或者这四个字在中文环境下是否说得通。如果是请回答「T」,否则回答「F」。请注意,即使这个词不是成语,如果说得通(也就是能念起来很通顺),你也该输出「T」。请不要包含任何解释,也不要包含任何标点符号。"
|
||||||
|
message = await llm.chat([{"role": "system", "content": system_prompt}, {"role": "user", "content": idiom}])
|
||||||
|
answer = message.content
|
||||||
|
logger.info(f"LLM 对成语 {idiom} 的判断结果是 {answer}")
|
||||||
|
if answer == "T":
|
||||||
|
await cls.storage_idiom(idiom)
|
||||||
|
return answer == "T"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def storage_idiom(cls, idiom: str):
|
||||||
|
# 将 idiom 存入数据库
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||||
|
(idiom,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IdiomGame:
|
class IdiomGame:
|
||||||
ALL_WORDS = [] # 所有四字词语
|
# ALL_WORDS = [] # 所有四字词语
|
||||||
ALL_IDIOMS = [] # 所有成语
|
# ALL_IDIOMS = [] # 所有成语
|
||||||
INSTANCE_LIST: dict[str, "IdiomGame"] = {} # 群号对应的游戏实例
|
INSTANCE_LIST: dict[str, "IdiomGame"] = {} # 群号对应的游戏实例
|
||||||
IDIOM_FIRST_CHAR = {} # 所有成语包括词语的首字字典
|
# IDIOM_FIRST_CHAR = {} # 所有成语包括词语的首字字典
|
||||||
AVALIABLE_IDIOM_FIRST_CHAR = {} # 真正有效的成语首字字典
|
# AVALIABLE_IDIOM_FIRST_CHAR = {} # 真正有效的成语首字字典
|
||||||
|
|
||||||
__inited = False
|
__inited = False
|
||||||
|
|
||||||
@ -101,6 +148,16 @@ class IdiomGame:
|
|||||||
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
|
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
|
||||||
IdiomGame.INSTANCE_LIST[group_id] = self
|
IdiomGame.INSTANCE_LIST[group_id] = self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def append_into_word_list(cls, word: str):
|
||||||
|
'''
|
||||||
|
将一个新词加入到词语列表中
|
||||||
|
'''
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||||
|
(word,)
|
||||||
|
)
|
||||||
|
|
||||||
def be_able_to_play(self) -> bool:
|
def be_able_to_play(self) -> bool:
|
||||||
if self.last_play_date != datetime.date.today():
|
if self.last_play_date != datetime.date.today():
|
||||||
self.last_play_date = datetime.date.today()
|
self.last_play_date = datetime.date.today()
|
||||||
@ -110,21 +167,28 @@ class IdiomGame:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def choose_start_idiom(self) -> str:
|
@staticmethod
|
||||||
|
async def random_idiom() -> str:
|
||||||
|
result = await db_manager.query_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "random_choose_idiom.sql"
|
||||||
|
)
|
||||||
|
return result[0]["idiom"]
|
||||||
|
|
||||||
|
async def choose_start_idiom(self) -> str:
|
||||||
"""
|
"""
|
||||||
随机选择一个成语作为起始成语
|
随机选择一个成语作为起始成语
|
||||||
"""
|
"""
|
||||||
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
|
self.last_idiom = await IdiomGame.random_idiom()
|
||||||
self.last_char = self.last_idiom[-1]
|
self.last_char = self.last_idiom[-1]
|
||||||
if not self.is_nextable(self.last_char):
|
if not await self.is_nextable(self.last_char):
|
||||||
self.choose_start_idiom()
|
await self.choose_start_idiom()
|
||||||
else:
|
else:
|
||||||
self.add_history_idiom(self.last_idiom, new_chain=True)
|
self.add_history_idiom(self.last_idiom, new_chain=True)
|
||||||
return self.last_idiom
|
return self.last_idiom
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
|
async def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
|
||||||
cls.init_lexicon()
|
await cls.init_lexicon()
|
||||||
if not cls.INSTANCE_LIST.get(group_id):
|
if not cls.INSTANCE_LIST.get(group_id):
|
||||||
cls(group_id)
|
cls(group_id)
|
||||||
instance = cls.INSTANCE_LIST[group_id]
|
instance = cls.INSTANCE_LIST[group_id]
|
||||||
@ -135,10 +199,10 @@ class IdiomGame:
|
|||||||
instance.now_playing = True
|
instance.now_playing = True
|
||||||
return TryStartState.STARTED
|
return TryStartState.STARTED
|
||||||
|
|
||||||
def start_game(self, rounds: int = 100):
|
async def start_game(self, rounds: int = 100):
|
||||||
self.now_playing = True
|
self.now_playing = True
|
||||||
self.remain_rounds = rounds
|
self.remain_rounds = rounds
|
||||||
self.choose_start_idiom()
|
await self.choose_start_idiom()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def try_stop_game(cls, group_id: str) -> TryStopState:
|
def try_stop_game(cls, group_id: str) -> TryStopState:
|
||||||
@ -168,33 +232,37 @@ class IdiomGame:
|
|||||||
跳过当前成语,选择下一个成语
|
跳过当前成语,选择下一个成语
|
||||||
"""
|
"""
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
self._skip_idiom_async()
|
await self._skip_idiom_async()
|
||||||
self.add_buff_score(buff_score)
|
self.add_buff_score(buff_score)
|
||||||
return self.last_idiom
|
return self.last_idiom
|
||||||
|
|
||||||
def _skip_idiom_async(self) -> str:
|
async def _skip_idiom_async(self) -> str:
|
||||||
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
|
self.last_idiom = await IdiomGame.random_idiom()
|
||||||
self.last_char = self.last_idiom[-1]
|
self.last_char = self.last_idiom[-1]
|
||||||
if not self.is_nextable(self.last_char):
|
if not await self.is_nextable(self.last_char):
|
||||||
self._skip_idiom_async()
|
await self._skip_idiom_async()
|
||||||
else:
|
else:
|
||||||
self.add_history_idiom(self.last_idiom, new_chain=True)
|
self.add_history_idiom(self.last_idiom, new_chain=True)
|
||||||
return self.last_idiom
|
return self.last_idiom
|
||||||
|
|
||||||
async def try_verify_idiom(self, idiom: str, user_id: str) -> TryVerifyState:
|
async def try_verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
||||||
"""
|
"""
|
||||||
用户发送成语
|
用户发送成语
|
||||||
"""
|
"""
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
state = self._verify_idiom(idiom, user_id)
|
state = await self._verify_idiom(idiom, user_id)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def is_nextable(self, last_char: str) -> bool:
|
async def is_nextable(self, last_char: str) -> bool:
|
||||||
"""
|
"""
|
||||||
判断是否有成语可以接
|
判断是否有成语可以接
|
||||||
"""
|
"""
|
||||||
return last_char in IdiomGame.AVALIABLE_IDIOM_FIRST_CHAR
|
result = await db_manager.query_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "is_nextable.sql",
|
||||||
|
(last_char,)
|
||||||
|
)
|
||||||
|
return result[0]["DEED"] == 1
|
||||||
|
|
||||||
def add_already_idiom(self, idiom: str):
|
def add_already_idiom(self, idiom: str):
|
||||||
if idiom in self.already_idioms:
|
if idiom in self.already_idioms:
|
||||||
self.already_idioms[idiom] += 1
|
self.already_idioms[idiom] += 1
|
||||||
@ -218,16 +286,30 @@ class IdiomGame:
|
|||||||
result.append(" -> ".join(chain))
|
result.append(" -> ".join(chain))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
async def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
||||||
state = []
|
state = []
|
||||||
# 新成语的首字应与上一条成语的尾字相同
|
# 新成语的首字应与上一条成语的尾字相同
|
||||||
if idiom[0] != self.last_char:
|
if idiom[0] != self.last_char:
|
||||||
state.append(TryVerifyState.WRONG_FIRST_CHAR)
|
state.append(TryVerifyState.WRONG_FIRST_CHAR)
|
||||||
return state
|
return state
|
||||||
if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
|
# 成语是否存在
|
||||||
self.add_score(user_id, -0.1)
|
result = await db_manager.query_by_sql_file(
|
||||||
state.append(TryVerifyState.NOT_IDIOM)
|
ROOT_PATH / "sql" / "query_idiom.sql",
|
||||||
return state
|
(idiom, idiom, idiom)
|
||||||
|
)
|
||||||
|
status_result = result[0]["status"]
|
||||||
|
if status_result == -1:
|
||||||
|
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
|
||||||
|
try:
|
||||||
|
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
|
||||||
|
self.add_score(user_id, -0.1)
|
||||||
|
state.append(TryVerifyState.NOT_IDIOM)
|
||||||
|
return state
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM 验证成语 {idiom} 时出现错误:{e}")
|
||||||
|
self.add_score(user_id, -0.1)
|
||||||
|
state.append(TryVerifyState.NOT_IDIOM)
|
||||||
|
return state
|
||||||
# 成语合法,更新状态
|
# 成语合法,更新状态
|
||||||
self.add_history_idiom(idiom)
|
self.add_history_idiom(idiom)
|
||||||
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
|
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
|
||||||
@ -238,16 +320,16 @@ class IdiomGame:
|
|||||||
self.last_idiom = idiom
|
self.last_idiom = idiom
|
||||||
self.last_char = idiom[-1]
|
self.last_char = idiom[-1]
|
||||||
self.add_score(user_id, 1 * score_k) # 先加 1 分
|
self.add_score(user_id, 1 * score_k) # 先加 1 分
|
||||||
if idiom in IdiomGame.ALL_IDIOMS:
|
if status_result == 1:
|
||||||
state.append(TryVerifyState.VERIFIED_AND_REAL)
|
state.append(TryVerifyState.VERIFIED_AND_REAL)
|
||||||
self.add_score(user_id, 4 * score_k) # 再加 4 分
|
self.add_score(user_id, 4 * score_k) # 再加 4 分
|
||||||
self.remain_rounds -= 1
|
self.remain_rounds -= 1
|
||||||
if self.remain_rounds <= 0:
|
if self.remain_rounds <= 0:
|
||||||
self.now_playing = False
|
self.now_playing = False
|
||||||
state.append(TryVerifyState.GAME_END)
|
state.append(TryVerifyState.GAME_END)
|
||||||
if not self.is_nextable(self.last_char):
|
if not await self.is_nextable(self.last_char):
|
||||||
# 没有成语可以接了,自动跳过
|
# 没有成语可以接了,自动跳过
|
||||||
self._skip_idiom_async()
|
await self._skip_idiom_async()
|
||||||
self.add_buff_score(-100)
|
self.add_buff_score(-100)
|
||||||
state.append(TryVerifyState.BUT_NO_NEXT)
|
state.append(TryVerifyState.BUT_NO_NEXT)
|
||||||
return state
|
return state
|
||||||
@ -274,16 +356,23 @@ class IdiomGame:
|
|||||||
return self.last_char
|
return self.last_char
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
|
async def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
|
||||||
cls.init_lexicon()
|
await cls.init_lexicon()
|
||||||
if first_char not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
|
result = await db_manager.query_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "query_idiom_start_with.sql",
|
||||||
|
(first_char,)
|
||||||
|
)
|
||||||
|
if len(result) == 0:
|
||||||
return None
|
return None
|
||||||
return secrets.choice(cls.AVALIABLE_IDIOM_FIRST_CHAR[first_char])
|
return result[0]["idiom"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init_lexicon(cls):
|
async def init_lexicon(cls):
|
||||||
if cls.__inited:
|
if cls.__inited:
|
||||||
return
|
return
|
||||||
|
await db_manager.execute_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "create_table.sql"
|
||||||
|
) # 确保数据库初始化
|
||||||
cls.__inited = True
|
cls.__inited = True
|
||||||
|
|
||||||
# 成语大表
|
# 成语大表
|
||||||
@ -291,11 +380,12 @@ class IdiomGame:
|
|||||||
ALL_IDIOMS_INFOS = json.load(f)
|
ALL_IDIOMS_INFOS = json.load(f)
|
||||||
|
|
||||||
# 词语大表
|
# 词语大表
|
||||||
|
ALL_WORDS = []
|
||||||
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
|
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
|
||||||
jsonData = json.load(f)
|
jsonData = json.load(f)
|
||||||
cls.ALL_WORDS = [item["ci"] for item in jsonData]
|
ALL_WORDS = [item["ci"] for item in jsonData]
|
||||||
logger.debug(f"Loaded {len(cls.ALL_WORDS)} words from ci.json")
|
logger.debug(f"Loaded {len(ALL_WORDS)} words from ci.json")
|
||||||
logger.debug(f"Sample words: {cls.ALL_WORDS[:5]}")
|
logger.debug(f"Sample words: {ALL_WORDS[:5]}")
|
||||||
|
|
||||||
COMMON_WORDS = []
|
COMMON_WORDS = []
|
||||||
# 读取 COMMON 词语大表
|
# 读取 COMMON 词语大表
|
||||||
@ -335,29 +425,55 @@ class IdiomGame:
|
|||||||
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
|
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
|
||||||
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
|
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
|
||||||
|
|
||||||
|
# 读取本地的 idiom_llm_storage.txt 文件,补充词语表
|
||||||
|
LOCAL_LLM_WORDS = []
|
||||||
|
if (DATA_DIR / "idiom_llm_storage.txt").exists():
|
||||||
|
with open(DATA_DIR / "idiom_llm_storage.txt", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
word = line.strip()
|
||||||
|
if len(word) == 4:
|
||||||
|
LOCAL_LLM_WORDS.append(word)
|
||||||
|
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
|
||||||
|
|
||||||
# 只有成语的大表
|
# 只有成语的大表
|
||||||
cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
|
ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
|
||||||
cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
|
ALL_IDIOMS = list(set(ALL_IDIOMS)) # 去重
|
||||||
|
# 批量插入数据库
|
||||||
|
await db_manager.execute_many_values_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_idiom.sql",
|
||||||
|
[(idiom,) for idiom in ALL_IDIOMS]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 其他四字词语表,仅表示可以有这个词
|
# 其他四字词语表,仅表示可以有这个词
|
||||||
cls.ALL_WORDS = (
|
ALL_WORDS = (
|
||||||
[word for word in cls.ALL_WORDS if len(word) == 4]
|
[word for word in ALL_WORDS if len(word) == 4]
|
||||||
+ THUOCL_WORDS
|
+ THUOCL_WORDS
|
||||||
+ COMMON_WORDS
|
+ COMMON_WORDS
|
||||||
)
|
)
|
||||||
cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
|
# 插入数据库
|
||||||
|
await db_manager.execute_many_values_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "insert_word.sql",
|
||||||
|
[(word,) for word in ALL_WORDS]
|
||||||
|
)
|
||||||
|
|
||||||
# 根据成语大表,划分出成语首字字典
|
# 自定义词语 LOCAL_LLM_WORDS 插入数据库,兼容用
|
||||||
for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
|
await db_manager.execute_many_values_by_sql_file(
|
||||||
if idiom[0] not in cls.IDIOM_FIRST_CHAR:
|
ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||||
cls.IDIOM_FIRST_CHAR[idiom[0]] = []
|
[(word,) for word in LOCAL_LLM_WORDS]
|
||||||
cls.IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
|
)
|
||||||
|
|
||||||
# 根据真正的成语大表,划分出有效成语首字字典
|
# # 根据成语大表,划分出成语首字字典
|
||||||
for idiom in cls.ALL_IDIOMS:
|
# for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
|
||||||
if idiom[0] not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
|
# if idiom[0] not in cls.IDIOM_FIRST_CHAR:
|
||||||
cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]] = []
|
# cls.IDIOM_FIRST_CHAR[idiom[0]] = []
|
||||||
cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
|
# cls.IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
|
||||||
|
|
||||||
|
# # 根据真正的成语大表,划分出有效成语首字字典
|
||||||
|
# for idiom in cls.ALL_IDIOMS:
|
||||||
|
# if idiom[0] not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
|
||||||
|
# cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]] = []
|
||||||
|
# cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
|
||||||
|
|
||||||
|
|
||||||
evt = on_alconna(
|
evt = on_alconna(
|
||||||
@ -389,7 +505,7 @@ async def play_game(
|
|||||||
if rounds <= 0:
|
if rounds <= 0:
|
||||||
await evt.send(await UniMessage().text("干什么!你想玩负数局吗?").export())
|
await evt.send(await UniMessage().text("干什么!你想玩负数局吗?").export())
|
||||||
return
|
return
|
||||||
state = IdiomGame.try_start_game(group_id, force)
|
state = await IdiomGame.try_start_game(group_id, force)
|
||||||
if state == TryStartState.ALREADY_PLAYING:
|
if state == TryStartState.ALREADY_PLAYING:
|
||||||
await evt.send(
|
await evt.send(
|
||||||
await UniMessage()
|
await UniMessage()
|
||||||
@ -408,7 +524,7 @@ async def play_game(
|
|||||||
.export()
|
.export()
|
||||||
)
|
)
|
||||||
instance = IdiomGame.INSTANCE_LIST[group_id]
|
instance = IdiomGame.INSTANCE_LIST[group_id]
|
||||||
instance.start_game(rounds)
|
await instance.start_game(rounds)
|
||||||
# 发布成语
|
# 发布成语
|
||||||
await evt.send(
|
await evt.send(
|
||||||
await UniMessage()
|
await UniMessage()
|
||||||
@ -460,7 +576,9 @@ async def end_game(event: BaseEvent, group_id: str):
|
|||||||
for line in history_lines:
|
for line in history_lines:
|
||||||
result_text += line + "\n"
|
result_text += line + "\n"
|
||||||
await evt.send(await result_text.export())
|
await evt.send(await result_text.export())
|
||||||
instance.clear_score_board()
|
# instance.clear_score_board()
|
||||||
|
# 将实例删除
|
||||||
|
del IdiomGame.INSTANCE_LIST[group_id]
|
||||||
|
|
||||||
|
|
||||||
evt = on_alconna(
|
evt = on_alconna(
|
||||||
@ -499,7 +617,7 @@ async def _(target: DepLongTaskTarget):
|
|||||||
instance = IdiomGame.INSTANCE_LIST.get(group_id)
|
instance = IdiomGame.INSTANCE_LIST.get(group_id)
|
||||||
if not instance or not instance.get_playing_state():
|
if not instance or not instance.get_playing_state():
|
||||||
return
|
return
|
||||||
avaliable_idiom = IdiomGame.random_idiom_starting_with(instance.get_last_char())
|
avaliable_idiom = await IdiomGame.random_idiom_starting_with(instance.get_last_char())
|
||||||
# 发送哈哈狗图片
|
# 发送哈哈狗图片
|
||||||
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
|
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
|
||||||
img_data = f.read()
|
img_data = f.read()
|
||||||
|
|||||||
15
konabot/plugins/idiomgame/sql/create_table.sql
Normal file
15
konabot/plugins/idiomgame/sql/create_table.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- 创建成语大表
|
||||||
|
CREATE TABLE IF NOT EXISTS all_idioms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
idiom VARCHAR(128) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS all_words (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
word VARCHAR(128) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_words (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
word VARCHAR(128) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
3
konabot/plugins/idiomgame/sql/insert_custom_word.sql
Normal file
3
konabot/plugins/idiomgame/sql/insert_custom_word.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 插入自定义词
|
||||||
|
INSERT OR IGNORE INTO custom_words (word)
|
||||||
|
VALUES (?);
|
||||||
3
konabot/plugins/idiomgame/sql/insert_idiom.sql
Normal file
3
konabot/plugins/idiomgame/sql/insert_idiom.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 插入成语大表,避免重复插入
|
||||||
|
INSERT OR IGNORE INTO all_idioms (idiom)
|
||||||
|
VALUES (?);
|
||||||
3
konabot/plugins/idiomgame/sql/insert_word.sql
Normal file
3
konabot/plugins/idiomgame/sql/insert_word.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 插入词
|
||||||
|
INSERT OR IGNORE INTO all_words (word)
|
||||||
|
VALUES (?);
|
||||||
5
konabot/plugins/idiomgame/sql/is_nextable.sql
Normal file
5
konabot/plugins/idiomgame/sql/is_nextable.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- 查询是否有以 xx 开头的成语,有则返回真,否则假
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM all_idioms
|
||||||
|
WHERE idiom LIKE ? || '%'
|
||||||
|
) AS DEED;
|
||||||
7
konabot/plugins/idiomgame/sql/query_idiom.sql
Normal file
7
konabot/plugins/idiomgame/sql/query_idiom.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 查询成语是否在 all_idioms 中,如果存在则返回 1,否则再判断是否在 custom_words 或 all_words 中,存在则返回 0,否则返回 -1
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM all_idioms WHERE idiom = ?) THEN 1
|
||||||
|
WHEN EXISTS (SELECT 1 FROM custom_words WHERE word = ?) OR EXISTS (SELECT 1 FROM all_words WHERE word = ?) THEN 0
|
||||||
|
ELSE -1
|
||||||
|
END AS status;
|
||||||
4
konabot/plugins/idiomgame/sql/query_idiom_start_with.sql
Normal file
4
konabot/plugins/idiomgame/sql/query_idiom_start_with.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- 查询以 xx 开头的成语,随机打乱后只取第一个
|
||||||
|
SELECT idiom FROM all_idioms
|
||||||
|
WHERE idiom LIKE ? || '%'
|
||||||
|
ORDER BY RANDOM() LIMIT 1;
|
||||||
2
konabot/plugins/idiomgame/sql/random_choose_idiom.sql
Normal file
2
konabot/plugins/idiomgame/sql/random_choose_idiom.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- 随机从 all_idioms 表中选择一个成语
|
||||||
|
SELECT idiom FROM all_idioms ORDER BY RANDOM() LIMIT 1;
|
||||||
@ -1,24 +1,32 @@
|
|||||||
import re
|
import re
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import PIL
|
||||||
import PIL.Image
|
import PIL.Image
|
||||||
|
import cv2
|
||||||
|
import imageio.v3 as iio
|
||||||
from nonebot import on_message
|
from nonebot import on_message
|
||||||
from nonebot.adapters import Bot
|
from nonebot.adapters import Bot
|
||||||
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna
|
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.exc import BotExceptionMessage
|
||||||
from konabot.common.nb.extract_image import PIL_Image
|
from konabot.common.nb.extract_image import DepImageBytes, DepPILImage
|
||||||
from konabot.common.nb.match_keyword import match_keyword
|
from konabot.common.nb.match_keyword import match_keyword
|
||||||
from konabot.common.nb.reply_image import reply_image
|
from konabot.common.nb.reply_image import reply_image
|
||||||
|
|
||||||
|
# 保持不变
|
||||||
cmd_black_white = on_message(rule=match_keyword("黑白"))
|
cmd_black_white = on_message(rule=match_keyword("黑白"))
|
||||||
|
|
||||||
|
|
||||||
@cmd_black_white.handle()
|
@cmd_black_white.handle()
|
||||||
async def _(img: PIL_Image, bot: Bot):
|
async def _(img: DepPILImage, bot: Bot):
|
||||||
|
# 保持不变
|
||||||
await reply_image(cmd_black_white, bot, img.convert("LA"))
|
await reply_image(cmd_black_white, bot, img.convert("LA"))
|
||||||
|
|
||||||
|
|
||||||
|
# 保持不变
|
||||||
def parse_timestamp(tx: str) -> float | None:
|
def parse_timestamp(tx: str) -> float | None:
|
||||||
res = 0.0
|
res = 0.0
|
||||||
for component in tx.split(":"):
|
for component in tx.split(":"):
|
||||||
@ -29,6 +37,7 @@ def parse_timestamp(tx: str) -> float | None:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# 保持不变
|
||||||
cmd_giftool = on_alconna(
|
cmd_giftool = on_alconna(
|
||||||
Alconna(
|
Alconna(
|
||||||
"giftool",
|
"giftool",
|
||||||
@ -44,7 +53,7 @@ cmd_giftool = on_alconna(
|
|||||||
|
|
||||||
@cmd_giftool.handle()
|
@cmd_giftool.handle()
|
||||||
async def _(
|
async def _(
|
||||||
image: PIL_Image,
|
image: DepImageBytes,
|
||||||
start_point: str | None = None,
|
start_point: str | None = None,
|
||||||
frame_count: int | None = None,
|
frame_count: int | None = None,
|
||||||
length: str | None = None,
|
length: str | None = None,
|
||||||
@ -79,28 +88,24 @@ async def _(
|
|||||||
is_rev = speed_factor < 0
|
is_rev = speed_factor < 0
|
||||||
speed_factor = abs(speed_factor)
|
speed_factor = abs(speed_factor)
|
||||||
|
|
||||||
if not getattr(image, "is_animated", False):
|
|
||||||
raise BotExceptionMessage("错误:输入的不是动图(GIF)")
|
|
||||||
|
|
||||||
##
|
|
||||||
# 从这里开始,采样整个 GIF 图
|
|
||||||
frames: list[PIL.Image.Image] = []
|
|
||||||
durations: list[float] = []
|
|
||||||
try:
|
try:
|
||||||
for i in range(getattr(image, "n_frames")):
|
reader = iio.imread(BytesIO(image), extension=".gif", index=None)
|
||||||
image.seek(i)
|
np_frames = list(reader)
|
||||||
frames.append(image.copy())
|
|
||||||
duration = image.info.get("duration", 100) / 1000
|
_pil = PIL.Image.open(BytesIO(image))
|
||||||
durations.append(duration)
|
durations: list[float] = []
|
||||||
except EOFError:
|
while True:
|
||||||
pass
|
try:
|
||||||
if not frames:
|
duration = _pil.info.get('duration', 20)
|
||||||
|
durations.append(max(duration, 20) / 1000)
|
||||||
|
_pil.seek(_pil.tell() + 1)
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
raise BotExceptionMessage("错误:读取 GIF 帧失败")
|
raise BotExceptionMessage("错误:读取 GIF 帧失败")
|
||||||
# 采样结束
|
|
||||||
|
|
||||||
##
|
##
|
||||||
# 根据开始、结束时间或者帧数量来裁取 GIF 图
|
# 根据开始、结束时间或者帧数量来裁取 GIF 图
|
||||||
|
|
||||||
begin_time = ss or 0
|
begin_time = ss or 0
|
||||||
end_time = sum(durations)
|
end_time = sum(durations)
|
||||||
end_time = min(begin_time + (t or end_time), to or end_time, end_time)
|
end_time = min(begin_time + (t or end_time), to or end_time, end_time)
|
||||||
@ -108,94 +113,95 @@ async def _(
|
|||||||
accumulated = 0.0
|
accumulated = 0.0
|
||||||
status = 0
|
status = 0
|
||||||
|
|
||||||
sel_frames: list[PIL.Image.Image] = []
|
sel_np_frames: list[numpy.ndarray[Any, Any]] = []
|
||||||
sel_durations: list[float] = []
|
sel_durations: list[float] = []
|
||||||
|
|
||||||
for i in range(len(frames)):
|
for i in range(len(np_frames)):
|
||||||
frame = frames[i]
|
frame = np_frames[i]
|
||||||
duration = durations[i]
|
duration = durations[i]
|
||||||
|
|
||||||
if status == 0:
|
if status == 0:
|
||||||
if accumulated + duration > begin_time:
|
if accumulated + duration > begin_time:
|
||||||
status = 1
|
status = 1
|
||||||
sel_frames.append(frame)
|
sel_np_frames.append(frame)
|
||||||
sel_durations.append(accumulated + duration - begin_time)
|
sel_durations.append(accumulated + duration - begin_time)
|
||||||
|
elif accumulated + duration == begin_time:
|
||||||
|
status = 1
|
||||||
elif status == 1:
|
elif status == 1:
|
||||||
if accumulated + duration > end_time:
|
if accumulated + duration >= end_time:
|
||||||
sel_frames.append(frame)
|
included_duration = end_time - accumulated
|
||||||
sel_durations.append(end_time - accumulated)
|
if included_duration > 0:
|
||||||
|
sel_np_frames.append(frame)
|
||||||
|
sel_durations.append(included_duration)
|
||||||
break
|
break
|
||||||
sel_frames.append(frame)
|
sel_np_frames.append(frame)
|
||||||
sel_durations.append(duration)
|
sel_durations.append(duration)
|
||||||
|
|
||||||
accumulated += duration
|
accumulated += duration
|
||||||
|
|
||||||
##
|
if not sel_np_frames:
|
||||||
# 加速!
|
raise BotExceptionMessage("错误:裁取 GIF 帧失败(可能时间设置错误)")
|
||||||
sel_durations = [dur / speed_factor * 1000 for dur in durations]
|
|
||||||
|
|
||||||
rframes = []
|
rdur_ms_unprocessed = [dur / speed_factor * 1000 for dur in sel_durations]
|
||||||
rdur = []
|
rframes: list[numpy.ndarray] = []
|
||||||
|
rdur_ms: list[int] = []
|
||||||
|
|
||||||
acc_mod_20 = 0
|
acc_mod_20 = 0
|
||||||
|
|
||||||
for i in range(len(sel_frames)):
|
for i in range(len(sel_np_frames)):
|
||||||
fr = sel_frames[i]
|
fr = sel_np_frames[i]
|
||||||
du = round(sel_durations[i])
|
du = rdur_ms_unprocessed[i]
|
||||||
|
|
||||||
if du >= 20:
|
if du >= 20:
|
||||||
rframes.append(fr)
|
rframes.append(fr)
|
||||||
rdur.append(int(du))
|
rdur_ms.append(int(round(du)))
|
||||||
acc_mod_20 = 0
|
acc_mod_20 = 0
|
||||||
else:
|
else:
|
||||||
if acc_mod_20 == 0:
|
if acc_mod_20 == 0:
|
||||||
rframes.append(fr)
|
rframes.append(fr)
|
||||||
rdur.append(20)
|
rdur_ms.append(20)
|
||||||
acc_mod_20 += du
|
acc_mod_20 += du
|
||||||
else:
|
else:
|
||||||
acc_mod_20 += du
|
acc_mod_20 += du
|
||||||
if acc_mod_20 >= 20:
|
if acc_mod_20 >= 20:
|
||||||
acc_mod_20 = 0
|
acc_mod_20 = 0
|
||||||
|
|
||||||
if len(rframes) == 1 and len(sel_frames) > 1:
|
if len(rframes) == 1 and len(sel_np_frames) > 1:
|
||||||
rframes.append(sel_frames[max(2, len(sel_frames) // 2)])
|
middle_index = max(2, len(sel_np_frames) // 2)
|
||||||
rdur.append(20)
|
rframes.append(sel_np_frames[middle_index])
|
||||||
|
rdur_ms.append(20)
|
||||||
##
|
|
||||||
# 收尾:看看透明度这块
|
|
||||||
transparency_flag = False
|
|
||||||
for f in rframes:
|
|
||||||
if f.mode == "RGBA":
|
|
||||||
if any(pix < 255 for pix in f.getchannel("A").getdata()):
|
|
||||||
transparency_flag = True
|
|
||||||
break
|
|
||||||
elif f.mode == "P" and "transparency" in f.info:
|
|
||||||
transparency_flag = True
|
|
||||||
break
|
|
||||||
|
|
||||||
tf = {}
|
|
||||||
if transparency_flag:
|
|
||||||
tf["transparency"] = 0
|
|
||||||
|
|
||||||
if is_rev:
|
if is_rev:
|
||||||
rframes = rframes[::-1]
|
rframes = rframes[::-1]
|
||||||
rdur = rdur[::-1]
|
rdur_ms = rdur_ms[::-1]
|
||||||
|
|
||||||
output_img = BytesIO()
|
output_img = BytesIO()
|
||||||
|
|
||||||
if rframes:
|
if rframes:
|
||||||
rframes[0].save(
|
do_transparent = any((f.shape[2] == 4 for f in rframes))
|
||||||
output_img,
|
if do_transparent:
|
||||||
format="GIF",
|
rframes = [(
|
||||||
save_all=True,
|
f
|
||||||
append_images=rframes[1:],
|
if f.shape[2] == 4
|
||||||
duration=rdur,
|
else cv2.cvtColor(f, cv2.COLOR_RGB2RGBA)
|
||||||
loop=0,
|
) for f in rframes]
|
||||||
optimize=False,
|
kwargs = { "transparency": 0, "disposal": 2, "mode": "RGBA" }
|
||||||
disposal=2,
|
else:
|
||||||
**tf,
|
kwargs = {}
|
||||||
)
|
try:
|
||||||
|
iio.imwrite(
|
||||||
|
output_img,
|
||||||
|
rframes,
|
||||||
|
extension=".gif",
|
||||||
|
duration=rdur_ms,
|
||||||
|
loop=0,
|
||||||
|
optimize=True,
|
||||||
|
plugin="pillow",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise BotExceptionMessage(f"错误:写入 GIF 失败: {e}")
|
||||||
else:
|
else:
|
||||||
raise BotExceptionMessage("错误:没有可输出的帧")
|
raise BotExceptionMessage("错误:没有可输出的帧")
|
||||||
output_img.seek(0)
|
output_img.seek(0)
|
||||||
|
|
||||||
await cmd_giftool.send(await UniMessage().image(raw=output_img).export())
|
await cmd_giftool.send(await UniMessage().image(raw=output_img).export())
|
||||||
|
|||||||
@ -4,33 +4,32 @@ from math import ceil
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import on_message
|
from nonebot import on_message
|
||||||
|
import nonebot
|
||||||
|
from nonebot.rule import to_me
|
||||||
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
|
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
|
||||||
on_alconna)
|
on_alconna)
|
||||||
from nonebot_plugin_apscheduler import scheduler
|
from nonebot_plugin_apscheduler import scheduler
|
||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.nb.qq_broadcast import qq_broadcast
|
|
||||||
from konabot.plugins.kona_ph.core.message import (get_daily_report,
|
from konabot.plugins.kona_ph.core.message import (get_daily_report,
|
||||||
get_daily_report_v2,
|
get_daily_report_v2,
|
||||||
get_puzzle_description,
|
get_puzzle_description,
|
||||||
get_submission_message)
|
get_submission_message)
|
||||||
from konabot.plugins.kona_ph.core.storage import get_today_date
|
from konabot.plugins.kona_ph.core.storage import get_today_date
|
||||||
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE, config,
|
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE,
|
||||||
create_admin_commands,
|
create_admin_commands,
|
||||||
puzzle_manager)
|
puzzle_manager)
|
||||||
|
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
|
||||||
|
from konabot.plugins.poster.service import broadcast
|
||||||
|
|
||||||
create_admin_commands()
|
create_admin_commands()
|
||||||
|
register_poster_info("每日谜题", info=PosterInfo(
|
||||||
|
aliases={"konaph", "kona_ph", "KonaPH", "此方谜题", "KONAPH"},
|
||||||
|
description="此方 BOT 每日谜题推送",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
async def is_play_group(target: DepLongTaskTarget):
|
cmd_submit = on_message(rule=to_me())
|
||||||
if target.is_private_chat:
|
|
||||||
return True
|
|
||||||
if target.channel_id in config.plugin_puzzle_playgroup:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
cmd_submit = on_message(rule=is_play_group)
|
|
||||||
|
|
||||||
|
|
||||||
@cmd_submit.handle()
|
@cmd_submit.handle()
|
||||||
@ -52,7 +51,7 @@ async def _(msg: UniMsg, target: DepLongTaskTarget):
|
|||||||
|
|
||||||
cmd_query = on_alconna(Alconna(
|
cmd_query = on_alconna(Alconna(
|
||||||
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
||||||
), rule=is_play_group)
|
), rule=to_me())
|
||||||
|
|
||||||
@cmd_query.handle()
|
@cmd_query.handle()
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
@ -65,7 +64,7 @@ async def _(target: DepLongTaskTarget):
|
|||||||
|
|
||||||
cmd_query_submission = on_alconna(Alconna(
|
cmd_query_submission = on_alconna(Alconna(
|
||||||
"今日答题情况"
|
"今日答题情况"
|
||||||
), rule=is_play_group)
|
), rule=to_me())
|
||||||
|
|
||||||
@cmd_query_submission.handle()
|
@cmd_query_submission.handle()
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
@ -80,7 +79,7 @@ cmd_history = on_alconna(Alconna(
|
|||||||
"历史题目",
|
"历史题目",
|
||||||
Args["page?", int],
|
Args["page?", int],
|
||||||
Args["index_id?", str],
|
Args["index_id?", str],
|
||||||
), rule=is_play_group)
|
), rule=to_me())
|
||||||
|
|
||||||
@cmd_history.handle()
|
@cmd_history.handle()
|
||||||
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
||||||
@ -125,11 +124,15 @@ async def _():
|
|||||||
yesterday = get_today_date() - datetime.timedelta(days=1)
|
yesterday = get_today_date() - datetime.timedelta(days=1)
|
||||||
msg2 = get_daily_report(manager, yesterday)
|
msg2 = get_daily_report(manager, yesterday)
|
||||||
if msg2 is not None:
|
if msg2 is not None:
|
||||||
await qq_broadcast(config.plugin_puzzle_playgroup, msg2)
|
await broadcast("每日谜题", msg2)
|
||||||
|
|
||||||
puzzle = manager.get_today_puzzle()
|
puzzle = manager.get_today_puzzle()
|
||||||
if puzzle is not None:
|
if puzzle is not None:
|
||||||
logger.info(f"找到了题目 {puzzle.raw_id},发送")
|
logger.info(f"找到了题目 {puzzle.raw_id},发送")
|
||||||
await qq_broadcast(config.plugin_puzzle_playgroup, get_puzzle_description(puzzle))
|
await broadcast("每日谜题", get_puzzle_description(puzzle))
|
||||||
else:
|
else:
|
||||||
logger.info("自动任务:没有找到题目,跳过")
|
logger.info("自动任务:没有找到题目,跳过")
|
||||||
|
|
||||||
|
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ def get_puzzle_description(puzzle: Puzzle, with_answer: bool = False) -> UniMess
|
|||||||
if with_answer:
|
if with_answer:
|
||||||
result = result.text(f"\n\n题目答案:{puzzle.flag}")
|
result = result.text(f"\n\n题目答案:{puzzle.flag}")
|
||||||
else:
|
else:
|
||||||
result = result.text("\n\n输入「提交答案 答案」来提交你的解答")
|
result = result.text("\n\nAt 我或者私聊我「提交答案 答案」来提交你的解答")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import nonebot
|
import nonebot
|
||||||
import nonebot.adapters
|
import nonebot.adapters
|
||||||
import nonebot.adapters.discord
|
|
||||||
import nonebot.rule
|
import nonebot.rule
|
||||||
from nonebot import on_command
|
from nonebot import on_command
|
||||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||||
|
|
||||||
from konabot.common.nb.is_admin import is_admin
|
from konabot.common.nb.is_admin import is_admin
|
||||||
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
||||||
|
from konabot.plugins.markdown.core import MarkDownCore
|
||||||
|
|
||||||
def search_man(section: int) -> dict[tuple[int, str], Path]:
|
def search_man(section: int) -> dict[tuple[int, str], Path]:
|
||||||
base_path = {
|
base_path = {
|
||||||
@ -64,7 +64,7 @@ async def _(
|
|||||||
for section in section_set:
|
for section in section_set:
|
||||||
mans += [f"{n}({s})" for s, n in search_man(section).keys()]
|
mans += [f"{n}({s})" for s, n in search_man(section).keys()]
|
||||||
mans.sort()
|
mans.sort()
|
||||||
|
|
||||||
await man.send(UniMessage().text(
|
await man.send(UniMessage().text(
|
||||||
(
|
(
|
||||||
"★此方 BOT 使用帮助★\n"
|
"★此方 BOT 使用帮助★\n"
|
||||||
@ -94,9 +94,9 @@ async def _(
|
|||||||
await man.send(UniMessage().text("你所检索的指令不存在"))
|
await man.send(UniMessage().text("你所检索的指令不存在"))
|
||||||
return
|
return
|
||||||
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
||||||
if isinstance(event, nonebot.adapters.discord.event.MessageEvent):
|
# await man.send(UniMessage().text(mans_msg))
|
||||||
mans_msg = f'```\n{mans_msg}\n```'
|
img = await MarkDownCore.render_markdown(mans_msg)
|
||||||
await man.send(UniMessage().text(mans_msg))
|
await man.send(UniMessage.image(raw=img))
|
||||||
|
|
||||||
|
|
||||||
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
||||||
|
|||||||
72
konabot/plugins/markdown/__init__.py
Normal file
72
konabot/plugins/markdown/__init__.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from loguru import logger
|
||||||
|
import nonebot
|
||||||
|
from nonebot.adapters import Event as BaseEvent
|
||||||
|
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||||
|
from nonebot_plugin_alconna import (
|
||||||
|
UniMessage,
|
||||||
|
UniMsg
|
||||||
|
)
|
||||||
|
|
||||||
|
from konabot.plugins.markdown.core import MarkDownCore
|
||||||
|
|
||||||
|
def is_markdown_mentioned(msg: UniMsg) -> bool:
|
||||||
|
txt = msg.extract_plain_text()
|
||||||
|
if "markdown" not in txt[:8] and "md" not in txt[:2]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
evt = nonebot.on_message(rule=is_markdown_mentioned)
|
||||||
|
|
||||||
|
@evt.handle()
|
||||||
|
async def _(msg: UniMsg, event: BaseEvent):
|
||||||
|
if isinstance(event, DiscordMessageEvent):
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
else:
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
|
||||||
|
logger.debug(f"Received markdown command with content: {content}")
|
||||||
|
if "md" in content[:2]:
|
||||||
|
message = content.replace("md", "", 1).strip()
|
||||||
|
else:
|
||||||
|
message = content.replace("markdown", "", 1).strip()
|
||||||
|
# 如果回复了消息,则转换回复的内容
|
||||||
|
if(len(message) == 0):
|
||||||
|
if event.reply:
|
||||||
|
message = event.reply.message.extract_plain_text()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
logger.debug(f"Markdown content to render: {message}")
|
||||||
|
|
||||||
|
out = await MarkDownCore.render_markdown(message, theme="dark")
|
||||||
|
|
||||||
|
await evt.send(await UniMessage().image(raw=out).export())
|
||||||
|
|
||||||
|
|
||||||
|
def is_latex_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
|
||||||
|
txt = msg.extract_plain_text()
|
||||||
|
if "latex" not in txt[:5]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
evt = nonebot.on_message(rule=is_latex_mentioned)
|
||||||
|
|
||||||
|
@evt.handle()
|
||||||
|
async def _(msg: UniMsg, event: BaseEvent):
|
||||||
|
if isinstance(event, DiscordMessageEvent):
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
else:
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
|
||||||
|
logger.debug(f"Received markdown command with content: {content}")
|
||||||
|
message = content.replace("latex", "", 1).strip()
|
||||||
|
# 如果回复了消息,则转换回复的内容
|
||||||
|
if(len(message) == 0):
|
||||||
|
if event.reply:
|
||||||
|
message = event.reply.message.extract_plain_text()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
logger.debug(f"Latex content to render: {message}")
|
||||||
|
|
||||||
|
out = await MarkDownCore.render_latex(message, theme="dark")
|
||||||
|
|
||||||
|
await evt.send(await UniMessage().image(raw=out).export())
|
||||||
57
konabot/plugins/markdown/core.py
Normal file
57
konabot/plugins/markdown/core.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from loguru import logger
|
||||||
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
|
from konabot.common.web_render import konaweb
|
||||||
|
from konabot.common.web_render.core import WebRenderer
|
||||||
|
|
||||||
|
class MarkDownCore:
|
||||||
|
@staticmethod
|
||||||
|
async def render_markdown(markdown_text: str, theme: str = "dark", params: dict = {}) -> bytes:
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.emulate_media(color_scheme=theme)
|
||||||
|
|
||||||
|
await page.locator('textarea[name=content]').fill(markdown_text)
|
||||||
|
await page.locator('#button').click()
|
||||||
|
|
||||||
|
# 等待 checkState 函数加载完成
|
||||||
|
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
|
||||||
|
# 访问 checkState 函数,确保渲染完成
|
||||||
|
await page.wait_for_function("checkState() === true", timeout=1000)
|
||||||
|
|
||||||
|
out = await WebRenderer.render_with_persistent_page(
|
||||||
|
"markdown_renderer",
|
||||||
|
konaweb('markdown'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def render_latex(text: str, theme: str = "dark") -> bytes:
|
||||||
|
params = {
|
||||||
|
"size": "2.5em",
|
||||||
|
}
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.emulate_media(color_scheme=theme)
|
||||||
|
|
||||||
|
page.wait_for_selector('textarea[name=content]')
|
||||||
|
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
|
||||||
|
page.wait_for_selector('#button')
|
||||||
|
await page.locator('#button').click()
|
||||||
|
|
||||||
|
# 等待 checkState 函数加载完成
|
||||||
|
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
|
||||||
|
# 访问 checkState 函数,确保渲染完成
|
||||||
|
await page.wait_for_function("checkState() === true", timeout=10000)
|
||||||
|
|
||||||
|
out = await WebRenderer.render_with_persistent_page(
|
||||||
|
"latex_renderer",
|
||||||
|
konaweb('latex'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
@ -17,7 +17,8 @@ from nonebot_plugin_alconna import (
|
|||||||
)
|
)
|
||||||
from playwright.async_api import ConsoleMessage, Page
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
from konabot.common.nb.extract_image import PIL_Image, extract_image_from_message
|
from konabot.common.nb.match_keyword import match_keyword
|
||||||
|
from konabot.common.nb.extract_image import 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
|
||||||
@ -36,9 +37,6 @@ from konabot.plugins.memepack.drawing.saying import (
|
|||||||
)
|
)
|
||||||
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
|
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
|
||||||
|
|
||||||
from nonebot.adapters import Bot, Event
|
|
||||||
|
|
||||||
from returns.result import Success, Failure
|
|
||||||
|
|
||||||
geimao = on_alconna(
|
geimao = on_alconna(
|
||||||
Alconna(
|
Alconna(
|
||||||
@ -190,11 +188,11 @@ async def _(saying: list[str]):
|
|||||||
await cutecat.send(await UniMessage().image(raw=img_bytes).export())
|
await cutecat.send(await UniMessage().image(raw=img_bytes).export())
|
||||||
|
|
||||||
|
|
||||||
cao_display_cmd = on_message()
|
cao_display_cmd = on_message(rule=match_keyword("小槽展示"))
|
||||||
|
|
||||||
|
|
||||||
@cao_display_cmd.handle()
|
@cao_display_cmd.handle()
|
||||||
async def _(msg: UniMsg, evt: Event, bot: Bot):
|
async def _(msg: UniMsg, img: DepPILImage):
|
||||||
flag = False
|
flag = False
|
||||||
for text in cast(Iterable[Text], msg.get(Text)):
|
for text in cast(Iterable[Text], msg.get(Text)):
|
||||||
if text.text.strip() == "小槽展示":
|
if text.text.strip() == "小槽展示":
|
||||||
@ -205,20 +203,10 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
|
|||||||
return
|
return
|
||||||
if not flag:
|
if not flag:
|
||||||
return
|
return
|
||||||
match await extract_image_from_message(evt.get_message(), evt, bot):
|
img_handled = await draw_cao_display(img)
|
||||||
case Success(img):
|
img_bytes = BytesIO()
|
||||||
img_handled = await draw_cao_display(img)
|
img_handled.save(img_bytes, format="PNG")
|
||||||
img_bytes = BytesIO()
|
await cao_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
|
||||||
img_handled.save(img_bytes, format="PNG")
|
|
||||||
await cao_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
|
|
||||||
case Failure(err):
|
|
||||||
await cao_display_cmd.send(
|
|
||||||
await UniMessage()
|
|
||||||
.at(user_id=evt.get_user_id())
|
|
||||||
.text(" ")
|
|
||||||
.text(err)
|
|
||||||
.export()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
snaur_display_cmd = on_alconna(
|
snaur_display_cmd = on_alconna(
|
||||||
@ -235,7 +223,7 @@ snaur_display_cmd = on_alconna(
|
|||||||
|
|
||||||
@snaur_display_cmd.handle()
|
@snaur_display_cmd.handle()
|
||||||
async def _(
|
async def _(
|
||||||
img: PIL_Image,
|
img: DepPILImage,
|
||||||
whiteness: float = 0.0,
|
whiteness: float = 0.0,
|
||||||
black_level: float = 0.2,
|
black_level: float = 0.2,
|
||||||
opacity: float = 0.8,
|
opacity: float = 0.8,
|
||||||
@ -252,9 +240,9 @@ async def _(
|
|||||||
img_processed.save(img_data, "PNG")
|
img_processed.save(img_data, "PNG")
|
||||||
await snaur_display_cmd.send(await UniMessage().image(raw=img_data).export())
|
await snaur_display_cmd.send(await UniMessage().image(raw=img_data).export())
|
||||||
|
|
||||||
anan_display_cmd = on_message()
|
anan_display_cmd = on_message(rule=match_keyword("安安展示"))
|
||||||
@anan_display_cmd.handle()
|
@anan_display_cmd.handle()
|
||||||
async def _(msg: UniMsg, evt: Event, bot: Bot):
|
async def _(msg: UniMsg, img: DepPILImage):
|
||||||
flag = False
|
flag = False
|
||||||
for text in cast(Iterable[Text], msg.get(Text)):
|
for text in cast(Iterable[Text], msg.get(Text)):
|
||||||
stripped = text.text.strip()
|
stripped = text.text.strip()
|
||||||
@ -267,20 +255,10 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
|
|||||||
if not flag:
|
if not flag:
|
||||||
return
|
return
|
||||||
|
|
||||||
match await extract_image_from_message(evt.get_message(), evt, bot):
|
img_handled = await draw_anan_display(img)
|
||||||
case Success(img):
|
img_bytes = BytesIO()
|
||||||
img_handled = await draw_anan_display(img)
|
img_handled.save(img_bytes, format="PNG")
|
||||||
img_bytes = BytesIO()
|
await anan_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
|
||||||
img_handled.save(img_bytes, format="PNG")
|
|
||||||
await anan_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
|
|
||||||
case Failure(err):
|
|
||||||
await anan_display_cmd.send(
|
|
||||||
await UniMessage()
|
|
||||||
.at(user_id=evt.get_user_id())
|
|
||||||
.text(" ")
|
|
||||||
.text(err)
|
|
||||||
.export()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
kiosay = on_alconna(
|
kiosay = on_alconna(
|
||||||
@ -316,7 +294,7 @@ quote_cmd = on_alconna(Alconna(
|
|||||||
), aliases={"quote"})
|
), aliases={"quote"})
|
||||||
|
|
||||||
@quote_cmd.handle()
|
@quote_cmd.handle()
|
||||||
async def _(quote: str, author: str, img: PIL_Image):
|
async def _(quote: str, author: str, img: DepPILImage):
|
||||||
async with host_tempdir() as tempdir:
|
async with host_tempdir() as tempdir:
|
||||||
img_path = tempdir.path / "image.png"
|
img_path = tempdir.path / "image.png"
|
||||||
img_url = tempdir.url_of(img_path)
|
img_url = tempdir.url_of(img_path)
|
||||||
@ -351,7 +329,7 @@ doubao_cmd = on_alconna(Alconna(
|
|||||||
|
|
||||||
|
|
||||||
@doubao_cmd.handle()
|
@doubao_cmd.handle()
|
||||||
async def _(img: PIL_Image):
|
async def _(img: DepPILImage):
|
||||||
result = await draw_doubao_watermark(img)
|
result = await draw_doubao_watermark(img)
|
||||||
result_bytes = BytesIO()
|
result_bytes = BytesIO()
|
||||||
result.save(result_bytes, format="PNG")
|
result.save(result_bytes, format="PNG")
|
||||||
|
|||||||
@ -4,12 +4,17 @@ 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
|
||||||
|
|
||||||
doubao_watermark = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "doubao.png").convert("RGBA").resize((140, 40))
|
doubao_watermark = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "doubao.png").convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
@make_async
|
@make_async
|
||||||
def draw_doubao_watermark(base: PIL.Image.Image) -> PIL.Image.Image:
|
def draw_doubao_watermark(base: PIL.Image.Image) -> PIL.Image.Image:
|
||||||
base = base.copy().convert("RGBA")
|
base = base.copy().convert("RGBA")
|
||||||
base.alpha_composite(doubao_watermark, (base.size[0] - 160, base.size[1] - 60))
|
w = base.size[0] / 768 * 140
|
||||||
|
h = base.size[0] / 768 * 40
|
||||||
|
x = base.size[0] / 768 * 160
|
||||||
|
y = base.size[0] / 768 * 60
|
||||||
|
w, h, x, y = map(int, (w, h, x, y))
|
||||||
|
base.alpha_composite(doubao_watermark.resize((w, h)), (base.size[0] - x, base.size[1] - y))
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|||||||
85
konabot/plugins/poster/__init__.py
Normal file
85
konabot/plugins/poster/__init__.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import nonebot
|
||||||
|
from nonebot_plugin_alconna import Alconna, Args, on_alconna
|
||||||
|
|
||||||
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery
|
||||||
|
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
||||||
|
from konabot.plugins.poster.service import dep_poster_service
|
||||||
|
|
||||||
|
|
||||||
|
cmd_subscribe = on_alconna(Alconna(
|
||||||
|
"订阅",
|
||||||
|
Args["channel", str],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_subscribe.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, channel: str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.subscribe(channel, target)
|
||||||
|
if result:
|
||||||
|
await target.send_message(f"已订阅「{channel}」")
|
||||||
|
else:
|
||||||
|
await target.send_message(f"已经订阅过「{channel}」了")
|
||||||
|
|
||||||
|
|
||||||
|
cmd_list = on_alconna(Alconna(
|
||||||
|
"re:(?:查询|我的|获取)订阅(列表)?",
|
||||||
|
Args["page?", int],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def better_channel_message(channel_id: str) -> str:
|
||||||
|
if channel_id not in POSTER_INFO_DATA:
|
||||||
|
return channel_id
|
||||||
|
data = POSTER_INFO_DATA[channel_id]
|
||||||
|
return f"{channel_id}:{data.description}"
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_list.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.get_channels(target, PagerQuery(
|
||||||
|
page_index=page,
|
||||||
|
page_size=10,
|
||||||
|
))
|
||||||
|
await target.send_message(result.to_unimessage(title="订阅列表", formatter=better_channel_message))
|
||||||
|
|
||||||
|
|
||||||
|
cmd_list_available = on_alconna(Alconna(
|
||||||
|
"re:(查询)?可用订阅(列表)?",
|
||||||
|
Args["page?", int],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_list_available.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
|
result = PagerQuery(
|
||||||
|
page_index=page,
|
||||||
|
page_size=10,
|
||||||
|
).apply(sorted(POSTER_INFO_DATA.keys()))
|
||||||
|
await target.send_message(result.to_unimessage(title="可用订阅列表", formatter=better_channel_message))
|
||||||
|
|
||||||
|
|
||||||
|
cmd_unsubscribe = on_alconna(Alconna(
|
||||||
|
"取消订阅",
|
||||||
|
Args["channel", str],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_unsubscribe.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, channel: str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.subscribe(channel, target)
|
||||||
|
if result:
|
||||||
|
await target.send_message(f"已取消订阅「{channel}」")
|
||||||
|
else:
|
||||||
|
await target.send_message(f"这里没有订阅过「{channel}」")
|
||||||
|
|
||||||
|
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
@driver.on_startup
|
||||||
|
async def _():
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
await service.fix_data()
|
||||||
15
konabot/plugins/poster/poster_info.py
Normal file
15
konabot/plugins/poster/poster_info.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PosterInfo:
|
||||||
|
aliases: set[str] = field(default_factory=set)
|
||||||
|
description: str = field(default='')
|
||||||
|
|
||||||
|
|
||||||
|
POSTER_INFO_DATA: dict[str, PosterInfo] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_poster_info(channel: str, info: PosterInfo):
|
||||||
|
POSTER_INFO_DATA[channel] = info
|
||||||
|
|
||||||
112
konabot/plugins/poster/repo_local_data.py
Normal file
112
konabot/plugins/poster/repo_local_data.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
from konabot.common.path import DATA_PATH
|
||||||
|
from konabot.plugins.poster.repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelData(BaseModel):
|
||||||
|
targets: list[LongTaskTarget] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PosterData(BaseModel):
|
||||||
|
channels: dict[str, ChannelData] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_the_same_target(target1: LongTaskTarget, target2: LongTaskTarget) -> bool:
|
||||||
|
if (target1.is_private_chat and not target2.is_private_chat):
|
||||||
|
return False
|
||||||
|
if (target2.is_private_chat and not target1.is_private_chat):
|
||||||
|
return False
|
||||||
|
if target1.platform != target2.platform:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 如果是群聊,则要求 channel_id 相同
|
||||||
|
if not target1.is_private_chat:
|
||||||
|
return target1.channel_id == target2.channel_id
|
||||||
|
return target1.target_id == target2.target_id
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPosterRepo(IPosterRepo):
|
||||||
|
def __init__(self, data: PosterData) -> None:
|
||||||
|
self.data = data
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
|
||||||
|
if channel not in self.data.channels:
|
||||||
|
self.data.channels[channel] = ChannelData()
|
||||||
|
return self.data.channels[channel].targets
|
||||||
|
|
||||||
|
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
targets = await self.get_channel_targets(channel)
|
||||||
|
for t in targets:
|
||||||
|
if is_the_same_target(t, target):
|
||||||
|
return False
|
||||||
|
targets.append(target)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
targets = await self.get_channel_targets(channel)
|
||||||
|
len0 = len(targets)
|
||||||
|
self.data.channels[channel].targets = [
|
||||||
|
t for t in targets if not is_the_same_target(t, target)
|
||||||
|
]
|
||||||
|
len1 = len(self.data.channels[channel].targets)
|
||||||
|
return len0 != len1
|
||||||
|
|
||||||
|
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
channels: list[str] = []
|
||||||
|
for channel_id, channel in self.data.channels.items():
|
||||||
|
for t in channel.targets:
|
||||||
|
if is_the_same_target(target, t):
|
||||||
|
channels.append(channel_id)
|
||||||
|
break
|
||||||
|
channels = sorted(channels)
|
||||||
|
return pager.apply(channels)
|
||||||
|
|
||||||
|
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
|
||||||
|
channel_from = await self.get_channel_targets(from_channel)
|
||||||
|
channel_to = await self.get_channel_targets(to_channel)
|
||||||
|
|
||||||
|
for t1 in channel_from:
|
||||||
|
flag = True
|
||||||
|
for t2 in channel_to:
|
||||||
|
if is_the_same_target(t1, t2):
|
||||||
|
flag = False
|
||||||
|
break
|
||||||
|
if flag:
|
||||||
|
channel_to.append(t1)
|
||||||
|
|
||||||
|
del self.data.channels[from_channel]
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_POSTER_DATA_LOCK = asyncio.Lock()
|
||||||
|
LOCAL_POSTER_DATA_PATH = DATA_PATH / "module_poster_data.json"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def local_poster_data():
|
||||||
|
async with LOCAL_POSTER_DATA_LOCK:
|
||||||
|
if not LOCAL_POSTER_DATA_PATH.exists():
|
||||||
|
data = PosterData()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = PosterData.model_validate_json(LOCAL_POSTER_DATA_PATH.read_text())
|
||||||
|
except ValidationError:
|
||||||
|
data = PosterData()
|
||||||
|
yield data
|
||||||
|
LOCAL_POSTER_DATA_PATH.write_text(data.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def local_poster():
|
||||||
|
async with local_poster_data() as data:
|
||||||
|
yield LocalPosterRepo(data)
|
||||||
|
|
||||||
|
|
||||||
|
DepLocalPosterRepo = Annotated[LocalPosterRepo, Depends(local_poster)]
|
||||||
|
|
||||||
37
konabot/plugins/poster/repository.py
Normal file
37
konabot/plugins/poster/repository.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
|
||||||
|
|
||||||
|
class IPosterRepo(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
|
||||||
|
"""
|
||||||
|
获取广播通道的所有广播对象
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
"""
|
||||||
|
向广播通道添加一个广播目标。若目标已存在,则返回 False
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
"""
|
||||||
|
移除一个广播通道的目标。若目标不存在,则返回 False
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
"""
|
||||||
|
获得一个目标已经订阅了的广播通道
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
|
||||||
|
"""
|
||||||
|
合并两个 Channel 为一个,并移除另一个
|
||||||
|
"""
|
||||||
|
|
||||||
59
konabot/plugins/poster/service.py
Normal file
59
konabot/plugins/poster/service.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
||||||
|
from konabot.plugins.poster.repo_local_data import local_poster
|
||||||
|
from konabot.plugins.poster.repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
|
class PosterService:
|
||||||
|
def __init__(self, repo: IPosterRepo) -> None:
|
||||||
|
self.repo = repo
|
||||||
|
|
||||||
|
def parse_channel_id(self, channel: str):
|
||||||
|
for cid, cinfo in POSTER_INFO_DATA.items():
|
||||||
|
if channel in cinfo.aliases:
|
||||||
|
return cid
|
||||||
|
return channel
|
||||||
|
|
||||||
|
async def subscribe(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
return await self.repo.add_channel_target(channel, target)
|
||||||
|
|
||||||
|
async def unsubscribe(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
return await self.repo.remove_channel_target(channel, target)
|
||||||
|
|
||||||
|
async def broadcast(self, channel: str, message: UniMessage[Any] | str) -> list[LongTaskTarget]:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
targets = await self.repo.get_channel_targets(channel)
|
||||||
|
for target in targets:
|
||||||
|
# 因为是订阅消息,就不要 At 对方了
|
||||||
|
await target.send_message(message, at=False)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
async def get_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
return await self.repo.get_subscribed_channels(target, pager)
|
||||||
|
|
||||||
|
async def fix_data(self):
|
||||||
|
for cid, cinfo in POSTER_INFO_DATA.items():
|
||||||
|
for alias in cinfo.aliases:
|
||||||
|
await self.repo.merge_channel(alias, cid)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def dep_poster_service():
|
||||||
|
async with local_poster() as repo:
|
||||||
|
yield PosterService(repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(channel: str, message: UniMessage[Any] | str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
return await service.broadcast(channel, message)
|
||||||
|
|
||||||
|
|
||||||
|
DepPosterService = Annotated[PosterService, Depends(dep_poster_service)]
|
||||||
|
|
||||||
@ -6,7 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
import nanoid
|
import nanoid
|
||||||
import nonebot
|
import nonebot
|
||||||
import ptimeparse
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import get_plugin_config, on_message
|
from nonebot import get_plugin_config, on_message
|
||||||
from nonebot.adapters import Event
|
from nonebot.adapters import Event
|
||||||
@ -14,6 +13,7 @@ from nonebot_plugin_alconna import Alconna, Args, Subcommand, UniMessage, UniMsg
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
|
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
|
||||||
|
from konabot.common.ptimeparse import Parser
|
||||||
|
|
||||||
evt = on_message()
|
evt = on_message()
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ async def _(msg: UniMsg, mEvt: Event, target: DepLongTaskTarget):
|
|||||||
|
|
||||||
notify_time, notify_text = segments
|
notify_time, notify_text = segments
|
||||||
try:
|
try:
|
||||||
target_time = ptimeparse.Parser().parse(notify_time)
|
target_time = Parser().parse(notify_time)
|
||||||
logger.info(f"从 {notify_time} 解析出了时间:{target_time}")
|
logger.info(f"从 {notify_time} 解析出了时间:{target_time}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.info(f"无法从 {notify_time} 中解析出时间")
|
logger.info(f"无法从 {notify_time} 中解析出时间")
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot.adapters import Bot as BaseBot
|
|
||||||
from nonebot.adapters import Event as BaseEvent
|
|
||||||
from nonebot.plugin import PluginMetadata
|
from nonebot.plugin import PluginMetadata
|
||||||
from nonebot_plugin_alconna import Alconna, Args, Field, UniMessage, on_alconna
|
from nonebot_plugin_alconna import Alconna, Args, Field, UniMessage, on_alconna
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from returns.result import Failure, Success
|
import nonebot_plugin_alconna
|
||||||
|
|
||||||
from konabot.common.nb.extract_image import extract_image_from_message
|
from konabot.common.nb.extract_image import DepPILImage
|
||||||
|
|
||||||
__plugin_meta__ = PluginMetadata(
|
__plugin_meta__ = PluginMetadata(
|
||||||
name="ytpgif",
|
name="ytpgif",
|
||||||
@ -43,6 +41,7 @@ ytpgif_cmd = on_alconna(
|
|||||||
unmatch_tips=lambda x: f"“{x}”不是有效数值。{SPEED_TIPS}",
|
unmatch_tips=lambda x: f"“{x}”不是有效数值。{SPEED_TIPS}",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
Args["image?", nonebot_plugin_alconna.Image | None],
|
||||||
),
|
),
|
||||||
use_cmd_start=True,
|
use_cmd_start=True,
|
||||||
use_cmd_sep=False,
|
use_cmd_sep=False,
|
||||||
@ -63,7 +62,7 @@ def resize_frame(frame: Image.Image) -> Image.Image:
|
|||||||
|
|
||||||
|
|
||||||
@ytpgif_cmd.handle()
|
@ytpgif_cmd.handle()
|
||||||
async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0):
|
async def handle_ytpgif(src_img: DepPILImage, speed: float = 1.0):
|
||||||
# === 校验 speed 范围 ===
|
# === 校验 speed 范围 ===
|
||||||
if not (MIN_SPEED <= speed <= MAX_SPEED):
|
if not (MIN_SPEED <= speed <= MAX_SPEED):
|
||||||
await ytpgif_cmd.send(
|
await ytpgif_cmd.send(
|
||||||
@ -71,19 +70,6 @@ async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
match await extract_image_from_message(event.get_message(), event, bot):
|
|
||||||
case Success(img):
|
|
||||||
src_img = img
|
|
||||||
|
|
||||||
case Failure(msg):
|
|
||||||
await ytpgif_cmd.send(
|
|
||||||
await UniMessage.text(msg).export()
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
case _:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
n_frames = getattr(src_img, "n_frames", 1)
|
n_frames = getattr(src_img, "n_frames", 1)
|
||||||
@ -217,4 +203,4 @@ async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0):
|
|||||||
print(f"[YTPGIF] 处理失败: {e}")
|
print(f"[YTPGIF] 处理失败: {e}")
|
||||||
await ytpgif_cmd.send(
|
await ytpgif_cmd.send(
|
||||||
await UniMessage.text("❌ 处理失败,可能是图片格式不支持、文件损坏或过大。").export()
|
await UniMessage.text("❌ 处理失败,可能是图片格式不支持、文件损坏或过大。").export()
|
||||||
)
|
)
|
||||||
|
|||||||
2843
poetry.lock
generated
2843
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,6 @@ name = "konabot"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "在 MTTU 内部使用的 bot"
|
description = "在 MTTU 内部使用的 bot"
|
||||||
authors = [{ name = "passthem", email = "Passthem183@gmail.com" }]
|
authors = [{ name = "passthem", email = "Passthem183@gmail.com" }]
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12,<4.0"
|
requires-python = ">=3.12,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nonebot2[all] (>=2.4.3,<3.0.0)",
|
"nonebot2[all] (>=2.4.3,<3.0.0)",
|
||||||
@ -23,25 +22,33 @@ dependencies = [
|
|||||||
"skia-python (>=138.0,<139.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)",
|
||||||
"ptimeparse (>=0.2.1,<0.3.0)",
|
|
||||||
"nanoid (>=2.0.0,<3.0.0)",
|
"nanoid (>=2.0.0,<3.0.0)",
|
||||||
"opencc (>=1.1.9,<2.0.0)",
|
"opencc (>=1.1.9,<2.0.0)",
|
||||||
"playwright (>=1.55.0,<2.0.0)",
|
"playwright (>=1.55.0,<2.0.0)",
|
||||||
"openai (>=2.7.1,<3.0.0)",
|
"openai (>=2.7.1,<3.0.0)",
|
||||||
|
"imageio (>=2.37.2,<3.0.0)",
|
||||||
|
"aiosqlite (>=0.20.0,<1.0.0)",
|
||||||
|
"sqlparse (>=0.5.0,<1.0.0)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[[tool.poetry.source]]
|
|
||||||
name = "pt-gitea-pypi"
|
|
||||||
url = "https://gitea.service.jazzwhom.top/api/packages/Passthem/pypi/simple/"
|
|
||||||
priority = "supplemental"
|
|
||||||
|
|
||||||
[[tool.poetry.source]]
|
[[tool.poetry.source]]
|
||||||
name = "mirrors"
|
name = "mirrors"
|
||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||||
priority = "primary"
|
priority = "primary"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
|
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"rust-just (>=1.43.0,<2.0.0)",
|
||||||
|
"pytest (>=9.0.1,<10.0.0)",
|
||||||
|
"pytest-asyncio (>=1.3.0,<2.0.0)"
|
||||||
|
]
|
||||||
|
|||||||
15
scripts/test_playwright.py
Normal file
15
scripts/test_playwright.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import playwright.sync_api
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with playwright.sync_api.sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch()
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto("https://www.baidu.com")
|
||||||
|
print("Playwright + Chromium 环境正常")
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
@ -22,3 +22,11 @@ logger.info(f"已经加载的插件数量 {len(plugins)}")
|
|||||||
logger.info(f"期待加载的插件数量 {len_requires}")
|
logger.info(f"期待加载的插件数量 {len_requires}")
|
||||||
|
|
||||||
assert len(plugins) == len_requires
|
assert len(plugins) == len_requires
|
||||||
|
|
||||||
|
# 测试数据库模块是否可以正确导入
|
||||||
|
try:
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
|
logger.info("数据库模块导入成功")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"数据库模块导入失败: {e}")
|
||||||
|
raise
|
||||||
|
|||||||
@ -8,6 +8,8 @@ base = Path(__file__).parent.parent.absolute()
|
|||||||
def filter(change: Change, path: str) -> bool:
|
def filter(change: Change, path: str) -> bool:
|
||||||
if "__pycache__" in path:
|
if "__pycache__" in path:
|
||||||
return False
|
return False
|
||||||
if Path(path).absolute().is_relative_to(base / "data"):
|
if Path(path).absolute().is_relative_to((base / "data").absolute()):
|
||||||
|
return False
|
||||||
|
if Path(path).absolute().is_relative_to((base / ".git").absolute()):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
93
tests/test_database.py
Normal file
93
tests/test_database.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from konabot.common.database import DatabaseManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_database_manager():
|
||||||
|
"""测试数据库管理器的基本功能"""
|
||||||
|
# 创建临时数据库文件
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_file:
|
||||||
|
db_path = tmp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 初始化数据库管理器
|
||||||
|
db_manager = DatabaseManager(db_path)
|
||||||
|
|
||||||
|
# 创建测试表
|
||||||
|
create_table_sql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS test_users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
await db_manager.execute(create_table_sql)
|
||||||
|
|
||||||
|
# 插入测试数据
|
||||||
|
insert_sql = "INSERT INTO test_users (name, email) VALUES (?, ?)"
|
||||||
|
await db_manager.execute(insert_sql, ("张三", "zhangsan@example.com"))
|
||||||
|
await db_manager.execute(insert_sql, ("李四", "lisi@example.com"))
|
||||||
|
|
||||||
|
# 查询数据
|
||||||
|
select_sql = "SELECT * FROM test_users WHERE name = ?"
|
||||||
|
results = await db_manager.query(select_sql, ("张三",))
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "张三"
|
||||||
|
assert results[0]["email"] == "zhangsan@example.com"
|
||||||
|
|
||||||
|
# 测试使用Path对象
|
||||||
|
results = await db_manager.query_by_sql_file(Path(__file__), ("李四",))
|
||||||
|
# 注意:这里只是测试参数传递,实际SQL文件内容不是有效的SQL
|
||||||
|
|
||||||
|
# 关闭所有连接
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 清理临时文件
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_script():
|
||||||
|
"""测试执行SQL脚本功能"""
|
||||||
|
# 创建临时数据库文件
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_file:
|
||||||
|
db_path = tmp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 初始化数据库管理器
|
||||||
|
db_manager = DatabaseManager(db_path)
|
||||||
|
|
||||||
|
# 创建测试表的脚本
|
||||||
|
script = """
|
||||||
|
CREATE TABLE IF NOT EXISTS test_products (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price REAL
|
||||||
|
);
|
||||||
|
INSERT INTO test_products (name, price) VALUES ('苹果', 5.0);
|
||||||
|
INSERT INTO test_products (name, price) VALUES ('香蕉', 3.0);
|
||||||
|
"""
|
||||||
|
|
||||||
|
await db_manager.execute_script(script)
|
||||||
|
|
||||||
|
# 查询数据
|
||||||
|
results = await db_manager.query("SELECT * FROM test_products ORDER BY name")
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0]["name"] == "苹果"
|
||||||
|
assert results[1]["name"] == "香蕉"
|
||||||
|
|
||||||
|
# 关闭所有连接
|
||||||
|
await db_manager.close_all_connections()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 清理临时文件
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
os.unlink(db_path)
|
||||||
Reference in New Issue
Block a user