Compare commits
9 Commits
561f6981aa
...
database
| Author | SHA1 | Date | |
|---|---|---|---|
| 988965451b | |||
| f6fadb7226 | |||
| 0d540eea4c | |||
| f21da657db | |||
| a8a7b62f76 | |||
| 789500842c | |||
| 2f22f11d57 | |||
| eff25435e3 | |||
| df28fad697 |
@ -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
|
||||||
|
|||||||
@ -18,11 +18,6 @@ RUN apt-get update && \
|
|||||||
fonts-noto-color-emoji \
|
fonts-noto-color-emoji \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install --no-cache-dir playwright \
|
|
||||||
&& python -m playwright install chromium \
|
|
||||||
&& pip uninstall -y playwright
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
@ -46,6 +41,8 @@ COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN python -m playwright install chromium
|
||||||
|
|
||||||
COPY bot.py pyproject.toml .env.prod .env.test ./
|
COPY bot.py pyproject.toml .env.prod .env.test ./
|
||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|||||||
@ -71,6 +71,10 @@ code .
|
|||||||
|
|
||||||
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
||||||
|
|
||||||
|
#### 数据库配置
|
||||||
|
|
||||||
|
本项目使用SQLite作为数据库,默认数据库文件位于`./data/database.db`。可以通过设置`DATABASE_PATH`环境变量来指定其他位置。
|
||||||
|
|
||||||
### 运行
|
### 运行
|
||||||
|
|
||||||
使用命令行手动启动 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") # 第二个语句的参数
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
通过遵循这些指南和最佳实践,您可以充分利用本项目的异步数据库系统,构建高性能、安全的数据库应用。
|
||||||
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)]
|
||||||
|
|||||||
@ -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 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
生成二维码 - 将文本内容转换为二维码
|
|
||||||
|
|
||||||
格式
|
**生成二维码** - 将文本内容转换为二维码
|
||||||
生成二维码 <文本内容>
|
|
||||||
|
|
||||||
示例
|
### 格式
|
||||||
`生成二维码 嗨嗨嗨` 生成扫描结果为“嗨嗨嗨”的二维码图片
|
|
||||||
|
```
|
||||||
|
生成二维码 <文本内容>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
生成二维码 嗨嗨嗨
|
||||||
|
```
|
||||||
|
|
||||||
|
生成扫描结果为“嗨嗨嗨”的二维码图片
|
||||||
|
|||||||
@ -1,22 +1,30 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
订阅 - 收听此方 BOT 的自动消息发送
|
|
||||||
|
|
||||||
格式
|
**订阅** - 收听此方 BOT 的自动消息发送。
|
||||||
订阅 <频道名称>
|
|
||||||
取消订阅 <频道名称>
|
|
||||||
查询订阅 [页码]
|
|
||||||
可用订阅 [页码]
|
|
||||||
|
|
||||||
示例
|
---
|
||||||
`订阅 此方谜题`
|
|
||||||
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
|
|
||||||
如果你是私聊,则能够每天发送此方谜题到你的私聊;如果在群聊中使用该指令,则会每天发送题目到这个群里面。
|
|
||||||
|
|
||||||
`取消订阅 此方谜题`
|
## 格式
|
||||||
取消订阅「此方谜题」频道。
|
|
||||||
|
|
||||||
`查询订阅`
|
- `订阅 <频道名称>`
|
||||||
查询当前聊天上下文订阅的所有频道。
|
- `取消订阅 <频道名称>`
|
||||||
|
- `查询订阅 [页码]`
|
||||||
|
- `可用订阅 [页码]`
|
||||||
|
|
||||||
`可用订阅 2`
|
---
|
||||||
查询所有可用的订阅的第二页
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- **`订阅 此方谜题`**
|
||||||
|
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 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,17 +19,23 @@ 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
|
from konabot.common.llm import get_llm
|
||||||
|
|
||||||
|
ROOT_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
|
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
|
||||||
|
|
||||||
DATA_FILE_PATH = (
|
DATA_FILE_PATH = (
|
||||||
DATA_DIR / "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():
|
||||||
return []
|
return []
|
||||||
@ -58,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
|
||||||
@ -94,18 +116,19 @@ class IdiomGameLLM:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def storage_idiom(cls, idiom: str):
|
async def storage_idiom(cls, idiom: str):
|
||||||
# 将 idiom 存入本地文件以备后续分析
|
# 将 idiom 存入数据库
|
||||||
with open(DATA_DIR / "idiom_llm_storage.txt", "a", encoding="utf-8") as f:
|
await db_manager.execute_by_sql_file(
|
||||||
f.write(idiom + "\n")
|
ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||||
IdiomGame.append_into_word_list(idiom)
|
(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
|
||||||
|
|
||||||
@ -126,15 +149,14 @@ class IdiomGame:
|
|||||||
IdiomGame.INSTANCE_LIST[group_id] = self
|
IdiomGame.INSTANCE_LIST[group_id] = self
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def append_into_word_list(cls, word: str):
|
async def append_into_word_list(cls, word: str):
|
||||||
'''
|
'''
|
||||||
将一个新词加入到词语列表中
|
将一个新词加入到词语列表中
|
||||||
'''
|
'''
|
||||||
if word not in cls.ALL_WORDS:
|
await db_manager.execute_by_sql_file(
|
||||||
cls.ALL_WORDS.append(word)
|
ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||||
if word[0] not in cls.IDIOM_FIRST_CHAR:
|
(word,)
|
||||||
cls.IDIOM_FIRST_CHAR[word[0]] = []
|
)
|
||||||
cls.IDIOM_FIRST_CHAR[word[0]].append(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():
|
||||||
@ -145,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]
|
||||||
@ -170,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:
|
||||||
@ -203,20 +232,20 @@ 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]:
|
||||||
"""
|
"""
|
||||||
用户发送成语
|
用户发送成语
|
||||||
"""
|
"""
|
||||||
@ -224,12 +253,16 @@ class IdiomGame:
|
|||||||
state = await 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
|
||||||
@ -259,7 +292,13 @@ class IdiomGame:
|
|||||||
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:
|
# 成语是否存在
|
||||||
|
result = await db_manager.query_by_sql_file(
|
||||||
|
ROOT_PATH / "sql" / "query_idiom.sql",
|
||||||
|
(idiom, idiom, idiom)
|
||||||
|
)
|
||||||
|
status_result = result[0]["status"]
|
||||||
|
if status_result == -1:
|
||||||
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
|
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
|
||||||
try:
|
try:
|
||||||
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
|
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
|
||||||
@ -281,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
|
||||||
@ -317,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
|
||||||
|
|
||||||
# 成语大表
|
# 成语大表
|
||||||
@ -334,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 词语大表
|
||||||
@ -389,29 +436,44 @@ class IdiomGame:
|
|||||||
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
|
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
|
||||||
+ LOCAL_LLM_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(
|
||||||
@ -443,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()
|
||||||
@ -462,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()
|
||||||
@ -514,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(
|
||||||
@ -553,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())
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
202
poetry.lock
generated
202
poetry.lock
generated
@ -209,6 +209,30 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosqlite"
|
||||||
|
version = "0.21.0"
|
||||||
|
description = "asyncio bridge to the standard sqlite3 module"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0"},
|
||||||
|
{file = "aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
typing_extensions = ">=4.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["attribution (==1.7.1)", "black (==24.3.0)", "build (>=1.2)", "coverage[toml] (==7.6.10)", "flake8 (==7.0.0)", "flake8-bugbear (==24.12.12)", "flit (==3.10.1)", "mypy (==1.14.1)", "ufmt (==2.5.1)", "usort (==1.0.8.post1)"]
|
||||||
|
docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
version = "0.0.3"
|
version = "0.0.3"
|
||||||
@ -946,12 +970,12 @@ version = "0.4.6"
|
|||||||
description = "Cross-platform colored terminal text."
|
description = "Cross-platform colored terminal text."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
|
|
||||||
files = [
|
files = [
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
]
|
]
|
||||||
|
markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
|
||||||
|
|
||||||
[package.source]
|
[package.source]
|
||||||
type = "legacy"
|
type = "legacy"
|
||||||
@ -1469,6 +1493,45 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imageio"
|
||||||
|
version = "2.37.2"
|
||||||
|
description = "Read and write images and video across all major formats. Supports scientific and volumetric data."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b"},
|
||||||
|
{file = "imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
numpy = "*"
|
||||||
|
pillow = ">=8.3.2"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
all-plugins = ["astropy", "av", "fsspec[http]", "imageio-ffmpeg", "numpy (>2)", "pillow-heif", "psutil", "rawpy", "tifffile"]
|
||||||
|
all-plugins-pypy = ["fsspec[http]", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"]
|
||||||
|
dev = ["black", "flake8", "fsspec[github]", "pytest", "pytest-cov"]
|
||||||
|
docs = ["numpydoc", "pydata-sphinx-theme", "sphinx (<6)"]
|
||||||
|
ffmpeg = ["imageio-ffmpeg", "psutil"]
|
||||||
|
fits = ["astropy"]
|
||||||
|
freeimage = ["fsspec[http]"]
|
||||||
|
full = ["astropy", "av", "black", "flake8", "fsspec[github,http]", "imageio-ffmpeg", "numpy (>2)", "numpydoc", "pillow-heif", "psutil", "pydata-sphinx-theme", "pytest", "pytest-cov", "rawpy", "sphinx (<6)", "tifffile"]
|
||||||
|
gdal = ["gdal"]
|
||||||
|
itk = ["itk"]
|
||||||
|
linting = ["black", "flake8"]
|
||||||
|
pillow-heif = ["pillow-heif"]
|
||||||
|
pyav = ["av"]
|
||||||
|
rawpy = ["numpy (>2)", "rawpy"]
|
||||||
|
test = ["fsspec[github]", "pytest", "pytest-cov"]
|
||||||
|
tifffile = ["tifffile"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imagetext-py"
|
name = "imagetext-py"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@ -1529,6 +1592,23 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
description = "brain-dead simple config-ini parsing"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||||
|
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jiter"
|
name = "jiter"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@ -2640,6 +2720,23 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
description = "Core utilities for Python packages"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||||
|
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "11.3.0"
|
version = "11.3.0"
|
||||||
@ -2819,6 +2916,27 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
description = "plugin and hook calling mechanisms for python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||||
|
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["pre-commit", "tox"]
|
||||||
|
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -3305,7 +3423,7 @@ version = "2.19.2"
|
|||||||
description = "Pygments is a syntax highlighting package written in Python."
|
description = "Pygments is a syntax highlighting package written in Python."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||||
@ -3336,6 +3454,58 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.1"
|
||||||
|
description = "pytest: simple powerful testing with Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"},
|
||||||
|
{file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||||
|
iniconfig = ">=1.0.1"
|
||||||
|
packaging = ">=22"
|
||||||
|
pluggy = ">=1.5,<2"
|
||||||
|
pygments = ">=2.7.2"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
description = "Pytest support for asyncio"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
|
||||||
|
{file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
pytest = ">=8.2,<10"
|
||||||
|
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
|
||||||
|
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@ -3660,6 +3830,27 @@ type = "legacy"
|
|||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
reference = "mirrors"
|
reference = "mirrors"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.3"
|
||||||
|
description = "A non-validating SQL parser."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"},
|
||||||
|
{file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["build", "hatch"]
|
||||||
|
doc = ["sphinx"]
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "legacy"
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
reference = "mirrors"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "0.49.3"
|
version = "0.49.3"
|
||||||
@ -3863,11 +4054,12 @@ version = "4.15.0"
|
|||||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
]
|
]
|
||||||
|
markers = {dev = "python_version == \"3.12\""}
|
||||||
|
|
||||||
[package.source]
|
[package.source]
|
||||||
type = "legacy"
|
type = "legacy"
|
||||||
@ -4489,4 +4681,4 @@ reference = "mirrors"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.12,<4.0"
|
python-versions = ">=3.12,<4.0"
|
||||||
content-hash = "af9fc535dd8c4e33c2cac481839ba07bcb8014b9a9cbd6bd1b6f5942640ecefe"
|
content-hash = "2c341fdc0d5b29ad3b24516c46e036b2eff4c11e244047d114971039255c2ac4"
|
||||||
|
|||||||
@ -26,6 +26,9 @@ dependencies = [
|
|||||||
"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]
|
[tool.poetry]
|
||||||
@ -45,5 +48,7 @@ priority = "primary"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"rust-just (>=1.43.0,<2.0.0)"
|
"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
|
||||||
|
|||||||
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