Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 274ca0fa9a | |||
| c72cdd6a6b | |||
|
16b0451133
|
|||
|
cb34813c4b
|
|||
| 2de3be271e | |||
| f7d2168dac | |||
| 40be5ce335 | |||
| 8e6131473d | |||
|
26e10be4ec
|
|||
|
78bda5fc0a
|
|||
|
97658a6c56
|
|||
|
3fedc685a9
|
|||
|
d1a3e44c45
|
|||
|
f637778173
|
|||
|
145bfedf67
|
|||
|
61b9d733a5
|
|||
| ae59c20e2f | |||
| 0b7d21aeb0 | |||
|
d6ede3e6cd
|
|||
|
07ace8e6e9
|
|||
|
6f08c22b5b
|
|||
|
3e5c1941c8
|
|||
| f6e7dfcd93 | |||
| 1233677eea | |||
| 00bdb90e3c | |||
| 988965451b | |||
| f6fadb7226 | |||
| 0d540eea4c | |||
| f21da657db | |||
| a8a7b62f76 | |||
| 789500842c | |||
| 2f22f11d57 | |||
| eff25435e3 | |||
| df28fad697 | |||
| 561f6981aa | |||
| 2632215af9 | |||
| bfde559892 | |||
| 857f8c5955 | |||
| 500053e630 | |||
| 30cfb4cadd | |||
| e2f99af73b | |||
| e09de9eeb6 | |||
| 4a3b49ce79 | |||
| 03900f4416 | |||
| 62f4195e46 | |||
| 751297e3bc | |||
| b450998f3f | |||
| ae6297b98d | |||
| dacae29054 | |||
| 8acb546c6a | |||
| 49e0914416 | |||
| 5b74c78ec3 | |||
| c911410276 | |||
| 37ca4bf11f | |||
| 8ef084c22a | |||
| 57f0cd728f | |||
| 627a29f57e | |||
| 650c500f47 | |||
| 86acbe51e9 | |||
| 4900a7e0ad | |||
| 34da08126b | |||
| 00f416c8bc | |||
| 9c7d0a4486 | |||
| e3b9d6723f | |||
| ef80399a90 | |||
| bfbfa9d9be | |||
| 6b7be4d3b0 | |||
| 7c19c52d9f | |||
| a5f4ae9bdc | |||
| 9320815d3f | |||
| 795300cb83 | |||
| 0231aa04f4 | |||
| 01fe33eb9f | |||
| adfbac7d90 | |||
| 994c1412da | |||
| 8780dfec6f | |||
| 490d807e7a | |||
| fa208199ab | |||
| 38a17f42a3 | |||
| 37179fc4d7 | |||
| 56e0aabbf3 | |||
| ce2b7fd6f6 | |||
| b28f8f85a2 | |||
| 0acffea86d | |||
| 3e395f8a35 | |||
| 312e203bbe | |||
| f9deabfce0 | |||
| 0a822bf440 | |||
| 534a2c9e75 | |||
| a03cef4124 | |||
| 7a20c3fe2f | |||
| 16351792b6 | |||
| 7bbd4f81ee | |||
| 4d5678efac | |||
| c7229bb763 | |||
| 6abc963ccf | |||
| 881f38d187 | |||
| 56d32bc9f4 | |||
| 76f19f9eac | |||
| 1479d8f8da | |||
| 18785f034b | |||
| 7ba1a92623 | |||
| f6670eb672 | |||
| eb32c1af9a | |||
| e0c55545ec | |||
| 164305e81f | |||
| 96679033f3 | |||
| afda0680ec | |||
| 021133954e | |||
| 7baa04dbc2 | |||
| e55bdbdf4a |
30
.drone.yml
@ -38,6 +38,25 @@ steps:
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- docker run --rm gitea.service.jazzwhom.top/mttu-developers/konabot:nightly-${DRONE_COMMIT_SHA} python scripts/test_plugin_load.py
|
||||
- name: 在容器中测试 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
|
||||
image: parrazam/drone-ntfy
|
||||
when:
|
||||
status: [success, failure]
|
||||
settings:
|
||||
url: https://ntfy.service.jazzwhom.top
|
||||
topic: drone_ci
|
||||
tags:
|
||||
- drone-ci
|
||||
token:
|
||||
from_secret: NTFY_TOKEN
|
||||
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
@ -74,6 +93,17 @@ steps:
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
path: /var/run/docker.sock
|
||||
- name: 发送构建结果到 ntfy
|
||||
image: parrazam/drone-ntfy
|
||||
when:
|
||||
status: [success, failure]
|
||||
settings:
|
||||
url: https://ntfy.service.jazzwhom.top
|
||||
topic: drone_ci
|
||||
tags:
|
||||
- drone-ci
|
||||
token:
|
||||
from_secret: NTFY_TOKEN
|
||||
|
||||
volumes:
|
||||
- name: docker-socket
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ENVIRONMENT=dev
|
||||
PORT=21333
|
||||
|
||||
DATABASE_PATH="./data/database.db"
|
||||
ENABLE_CONSOLE=true
|
||||
|
||||
9
.gitignore
vendored
@ -1,4 +1,11 @@
|
||||
# 基本的数据文件,以及环境用文件
|
||||
/.env
|
||||
/data
|
||||
/pyrightconfig.json
|
||||
/pyrightconfig.toml
|
||||
|
||||
__pycache__
|
||||
# 缓存文件
|
||||
__pycache__
|
||||
|
||||
# 可能会偶然生成的 diff 文件
|
||||
/*.diff
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.REPL.enableREPLSmartSend": false
|
||||
}
|
||||
36
Dockerfile
@ -1,33 +1,37 @@
|
||||
# copied from https://www.martinrichards.me/post/python_poetry_docker/
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers
|
||||
|
||||
# 安装所有都需要的底层依赖
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libfontconfig1 \
|
||||
libgl1 \
|
||||
libegl1 \
|
||||
libglvnd0 \
|
||||
mesa-vulkan-drivers \
|
||||
libfontconfig1 libgl1 libegl1 libglvnd0 mesa-vulkan-drivers at-spi2-common fontconfig \
|
||||
libasound2-data libavahi-client3 libavahi-common-data libavahi-common3 libdatrie1 \
|
||||
libfontenc1 libfribidi0 libgraphite2-3 libharfbuzz0b libice6 libpixman-1-0 \
|
||||
libsm6 libthai-data libthai0 libunwind8 libxaw7 libxcb-render0 libxfont2 libxi6 \
|
||||
libxkbfile1 libxmu6 libxpm4 libxrender1 libxt6t64 x11-common x11-xkb-utils \
|
||||
xfonts-encodings xfonts-utils xkb-data xserver-common libnspr4 libatk1.0-0t64 \
|
||||
libatk-bridge2.0-0t64 libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 \
|
||||
libxkbcommon0 libasound2t64 libnss3 fonts-noto-cjk fonts-noto-cjk-extra \
|
||||
fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
# 安装构建依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install poetry
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN python -m poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
||||
RUN uv sync --no-install-project
|
||||
|
||||
|
||||
|
||||
@ -37,6 +41,8 @@ COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN python -m playwright install chromium
|
||||
|
||||
COPY bot.py pyproject.toml .env.prod .env.test ./
|
||||
COPY assets ./assets
|
||||
COPY scripts ./scripts
|
||||
|
||||
187
QWEN.md
Normal file
@ -0,0 +1,187 @@
|
||||
# Konabot Project Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
Konabot is a multi-platform chatbot built using the NoneBot2 framework, primarily used within MTTU (likely an organization or community). The bot supports multiple adapters including Discord, QQ (via Onebot), Minecraft, and Console interfaces.
|
||||
|
||||
### Key Features
|
||||
- Multi-platform support (Discord, QQ, Minecraft, Console)
|
||||
- Rich plugin ecosystem with over 20 built-in plugins
|
||||
- Asynchronous database system with connection pooling (SQLite-based)
|
||||
- Advanced image processing capabilities
|
||||
- Integration with external services like Bilibili analysis
|
||||
- Support for Large Language Models (LLM)
|
||||
- Web rendering capabilities for advanced image generation
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: NoneBot2
|
||||
- **Language**: Python 3.12+
|
||||
- **Dependency Management**: Poetry
|
||||
- **Database**: SQLite with aiosqlite for async operations
|
||||
- **Build System**: Just (task runner)
|
||||
- **Containerization**: Docker
|
||||
- **CI/CD**: Drone CI
|
||||
- **Testing**: Pytest
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
konabot/
|
||||
├── bot.py # Main entry point
|
||||
├── pyproject.toml # Project dependencies and metadata
|
||||
├── justfile # Task definitions
|
||||
├── Dockerfile # Container build definition
|
||||
├── .drone.yml # CI/CD pipeline configuration
|
||||
├── konabot/ # Main source code
|
||||
│ ├── common/ # Shared utilities and modules
|
||||
│ │ ├── database/ # Async database manager with connection pooling
|
||||
│ │ ├── llm/ # Large Language Model integration
|
||||
│ │ ├── web_render/ # Web-based image rendering
|
||||
│ │ └── ... # Other utilities
|
||||
│ ├── plugins/ # Plugin modules (core functionality)
|
||||
│ │ ├── air_conditioner/
|
||||
│ │ ├── bilibili_fetch/
|
||||
│ │ ├── gen_qrcode/
|
||||
│ │ ├── hanzi/
|
||||
│ │ ├── idiomgame/
|
||||
│ │ ├── image_process/
|
||||
│ │ ├── roll_dice/
|
||||
│ │ ├── weather/
|
||||
│ │ └── ... (20+ plugins)
|
||||
│ └── test/
|
||||
├── tests/ # Test suite
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
├── assets/ # Static assets
|
||||
└── data/ # Runtime data storage
|
||||
```
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.12+
|
||||
- Git
|
||||
- Poetry (installed via pipx)
|
||||
|
||||
### Installation Steps
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://gitea.service.jazzwhom.top/Passthem/konabot.git
|
||||
cd konabot
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
3. Configure environment:
|
||||
- Copy `.env.example` to `.env`
|
||||
- Modify settings as needed for your platform adapters
|
||||
|
||||
### Platform Adapters Configuration
|
||||
- **Discord**: Set `ENABLE_DISCORD=true` and configure bot token
|
||||
- **QQ (Onebot)**: Set `ENABLE_QQ=true` and configure connection
|
||||
- **Console**: Enabled by default, disable with `ENABLE_CONSOLE=false`
|
||||
- **Minecraft**: Set `ENABLE_MINECRAFT=true`
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Development
|
||||
- Auto-reload development mode:
|
||||
```bash
|
||||
poetry run just watch
|
||||
```
|
||||
- Manual start:
|
||||
```bash
|
||||
poetry run python bot.py
|
||||
```
|
||||
|
||||
### Production
|
||||
- Docker container build and run:
|
||||
```bash
|
||||
docker build -t konabot .
|
||||
docker run konabot
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite with:
|
||||
```bash
|
||||
poetry run pytest
|
||||
```
|
||||
|
||||
Tests are located in the `tests/` directory and focus primarily on core functionality like the database manager.
|
||||
|
||||
## Database System
|
||||
|
||||
The project implements a custom asynchronous database manager (`konabot/common/database/__init__.py`) with these features:
|
||||
- Connection pooling for performance
|
||||
- Parameterized queries for security
|
||||
- SQL file execution support
|
||||
- Support for both string and Path objects for file paths
|
||||
- Automatic resource management
|
||||
|
||||
Example usage:
|
||||
```python
|
||||
from konabot.common.database import DatabaseManager
|
||||
|
||||
db = DatabaseManager()
|
||||
results = await db.query("SELECT * FROM users WHERE age > ?", (18,))
|
||||
await db.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("John", "john@example.com"))
|
||||
```
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Plugins are organized in `konabot/plugins/` and follow the NoneBot2 plugin structure. Each plugin typically consists of:
|
||||
- `__init__.py`: Main plugin logic using Alconna command parser
|
||||
- Supporting modules for specific functionality
|
||||
|
||||
Popular plugins include:
|
||||
- `roll_dice`: Dice rolling with image generation
|
||||
- `weather`: Weather radar image fetching
|
||||
- `bilibili_fetch`: Bilibili video analysis
|
||||
- `image_process`: Image manipulation tools
|
||||
- `markdown`: Markdown rendering
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Drone CI is configured with two pipelines:
|
||||
1. **Nightly builds**: Triggered on pushes to master branch
|
||||
2. **Release builds**: Triggered on git tags
|
||||
|
||||
Both pipelines:
|
||||
- Build Docker images
|
||||
- Test plugin loading
|
||||
- Verify Playwright functionality
|
||||
- Send notifications via ntfy
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- Use Poetry for dependency management
|
||||
- Follow NoneBot2 plugin development patterns
|
||||
- Write async code for database operations
|
||||
- Use Alconna for command parsing
|
||||
- Organize SQL queries in separate files when complex
|
||||
- Write tests for core functionality
|
||||
- Document features in the `docs/` directory
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
1. **Add a new plugin**:
|
||||
- Create a new directory in `konabot/plugins/`
|
||||
- Implement functionality in `__init__.py`
|
||||
- Use Alconna for command definition
|
||||
|
||||
2. **Database operations**:
|
||||
- Use the `DatabaseManager` class
|
||||
- Always parameterize queries
|
||||
- Store complex SQL in separate `.sql` files
|
||||
|
||||
3. **Image processing**:
|
||||
- Leverage existing utilities in `image_process` plugin
|
||||
- Use Pillow and Skia-Python for advanced graphics
|
||||
|
||||
4. **Testing**:
|
||||
- Add tests to the `tests/` directory
|
||||
- Use pytest with async support
|
||||
- Mock external services when needed
|
||||
20
README.md
@ -1,4 +1,4 @@
|
||||
# 此方 bot!
|
||||
# konabot
|
||||
|
||||
在 MTTU 内部使用的 bot 一只。
|
||||
|
||||
@ -63,12 +63,24 @@ code .
|
||||
|
||||
配置 `ENABLE_CONSOLE=false`
|
||||
|
||||
#### 配置并支持 LLM(大语言模型)
|
||||
|
||||
详见[LLM 配置文档](/docs/LLM.md)。
|
||||
|
||||
#### 配置 konabot-web 以支持更高级的图片渲染
|
||||
|
||||
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
||||
|
||||
#### 数据库配置
|
||||
|
||||
本项目使用SQLite作为数据库,默认数据库文件位于`./data/database.db`。可以通过设置`DATABASE_PATH`环境变量来指定其他位置。
|
||||
|
||||
### 运行
|
||||
|
||||
使用命令行手动启动 Bot:
|
||||
|
||||
```bash
|
||||
poetry run watchfiles bot.main konabot
|
||||
poetry run just watch
|
||||
```
|
||||
|
||||
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
||||
@ -83,3 +95,7 @@ poetry run python bot.py
|
||||
- [事件响应器](https://nonebot.dev/docs/tutorial/matcher)
|
||||
- [事件处理](https://nonebot.dev/docs/tutorial/handler)
|
||||
- [Alconna 插件](https://nonebot.dev/docs/best-practice/alconna/)
|
||||
|
||||
## 数据库模块
|
||||
|
||||
本项目的数据库模块已更新为异步实现,使用连接池来提高性能,并支持现代的`pathlib.Path`参数类型。详细使用方法请参考[数据库使用文档](/docs/database.md)。
|
||||
|
||||
BIN
assets/img/ac/ac.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
assets/img/ac/broken_ac.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/img/ac/frozen_ac.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/img/dog/haha_dog.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/img/meme/doubao.png
Executable file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
assets/img/meme/kiosay.jpg
Executable file
|
After Width: | Height: | Size: 71 KiB |
BIN
assets/img/other/boom.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/webpage/ac/assets/background.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
76
assets/webpage/ac/index.html
Normal file
@ -0,0 +1,76 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>空调炸炸排行榜</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="text">位居全球第 <span id="ranking" class="ranking">200</span>!</div>
|
||||
<div class="text-2">您的群总共坏了 <span id="number" class="number">200</span> 台空调</div>
|
||||
<img class="background" src="./assets/background.png" alt="空调炸炸排行榜">
|
||||
</div>
|
||||
</body>
|
||||
<style>
|
||||
.box {
|
||||
position: relative;
|
||||
width: 1024px;
|
||||
}
|
||||
.number {
|
||||
font-size: 2em;
|
||||
color: #ffdd00;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bold;
|
||||
font-stretch: 50%;
|
||||
max-width: 520px;
|
||||
word-wrap: break-word;
|
||||
line-height: 0.8em;
|
||||
}
|
||||
.background {
|
||||
width: 1024px;
|
||||
}
|
||||
.text {
|
||||
position: absolute;
|
||||
top: 125px;
|
||||
width: 100%;
|
||||
font-size: 72px;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bolder;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
.text-2 {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
width: 100%;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bolder;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
.ranking {
|
||||
font-size: 2em;
|
||||
color: #ff0000;
|
||||
-webkit-text-stroke: #ffffff 2px;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bold;
|
||||
font-stretch: 50%;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// 从 URL 参数中获取 number 的值
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const number = urlParams.get('number');
|
||||
// 将 number 显示在页面上
|
||||
document.getElementById('number').textContent = number;
|
||||
// 从 URL 参数中获取 ranking 的值
|
||||
const ranking = urlParams.get('ranking');
|
||||
// 将 ranking 显示在页面上
|
||||
document.getElementById('ranking').textContent = ranking;
|
||||
</script>
|
||||
</html>
|
||||
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.nb.exc import BotExceptionMessage
|
||||
from konabot.common.path import LOG_PATH
|
||||
from konabot.common.database import get_global_db_manager
|
||||
|
||||
|
||||
dotenv.load_dotenv()
|
||||
env = os.environ.get("ENVIRONMENT", "prod")
|
||||
@ -48,6 +50,13 @@ def main():
|
||||
nonebot.load_plugins("konabot/plugins")
|
||||
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()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
65
docs/LLM.md
Normal file
@ -0,0 +1,65 @@
|
||||
# 大语言模型平台接入
|
||||
|
||||
为实现更多神秘小功能,此方 Bot 需要接入 AI。如果你需要参与开发或测试涉及 AI 的相关功能,麻烦请根据下面的文档继续操作。
|
||||
|
||||
## 配置项目接入 AI
|
||||
|
||||
AI 相关的配置文件在 `data/config/llm.json` 文件中。示例格式如下,这也将是到时候在云端的配置文件格式(给出的模型都会有):
|
||||
|
||||
```json
|
||||
{
|
||||
"llms": {
|
||||
"Qwen2.5-7B-Instruct": {
|
||||
"base_url": "https://api.siliconflow.cn/v1",
|
||||
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"model_name": "Qwen/Qwen2.5-7B-Instruct"
|
||||
},
|
||||
"qwen3-max": {
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"model_name": "qwen3-max"
|
||||
}
|
||||
},
|
||||
"default_llm": "Qwen2.5-7B-Instruct"
|
||||
}
|
||||
```
|
||||
|
||||
其中,形如 `qwen3-max` 的名称,是你在程序中调用 LLM 使用的键名。若不给出,则会默认使用配置文件中指定的默认模型。
|
||||
|
||||
```python
|
||||
from konabot.common.llm import get_llm
|
||||
|
||||
llm = get_llm() # 获得的是 Qwen2.5-7B-Instruct 模型
|
||||
llm = get_llm("qwen3-max") # 获得的是 qwen3-max 模型
|
||||
|
||||
message = await llm.chat([
|
||||
{ "role": "system", "content": "你是一只猫娘" },
|
||||
{ "role": "user", "content": "晚上好呀!" },
|
||||
], timeout=None, max_tokens=16384)
|
||||
# 获得了的是 openai.types.chat.ChatCompletionMessage 对象
|
||||
|
||||
print(f"AI 返回值:{message.content}") # 注意 content 可能为 None,需要做空值检测
|
||||
|
||||
client = llm.get_openai_client() # 获得的是一个 OpenAI Client 对象,可以做更多操作
|
||||
# 例如,调用 Embedding 模型来做知识库向量化等工作
|
||||
```
|
||||
|
||||
## 本项目使用的模型清单
|
||||
|
||||
为了便利大家使用,我在这里给出该项目将会使用的模型清单,请根据你的开发需求注册并选择你最喜欢的模型。如果需要接入新的模型,或者使用到文档之外的模型,欢迎在这里给出!
|
||||
|
||||
### 硅基流动 Qwen/Qwen2.5-7B-Instruct
|
||||
|
||||
一个 7B 大小的 AI 模型。其性能不太能指望,但是它小,而且比较快,可以做一些轻量的操作。
|
||||
|
||||
该模型是免费的,但是也需要你注册[硅基流动](https://cloud.siliconflow.cn/me/models)账号,并生成 `api_key` 添加到配置文件中。
|
||||
|
||||
### 通义千问 qwen3-max
|
||||
|
||||
贵但是很先进的最新模型,其能力可以信赖。但是不要拿它做大量工作哦!
|
||||
|
||||
在[百炼大模型平台](https://bailian.console.aliyun.com/)注册账号并申请 `api_key`,新用户会赠送 1M tokens,足够做测试了。
|
||||
|
||||
## 安全须知
|
||||
|
||||
请注意提防 AI 越狱等情况。
|
||||
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") # 第二个语句的参数
|
||||
])
|
||||
```
|
||||
|
||||
通过遵循这些指南和最佳实践,您可以充分利用本项目的异步数据库系统,构建高性能、安全的数据库应用。
|
||||
18
docs/konabot-web.md
Normal file
@ -0,0 +1,18 @@
|
||||
# konabot-web 配置文档
|
||||
|
||||
本文档教你配置一个此方 Bot 的 Web 服务器。
|
||||
|
||||
## 安装并运行 konabot-web
|
||||
|
||||
按照 [konabot-web README](https://gitea.service.jazzwhom.top/mttu-developers/konabot-web) 安装并运行 konabot-web 实例。
|
||||
|
||||
## 指定 konabot-web 实例地址
|
||||
|
||||
如果你的 Web 服务器的端口不是 5173,或者你有特殊的网络结构,你需要手动设置 konabot-web。编辑 `.env` 文件:
|
||||
|
||||
```
|
||||
MODULE_WEB_RENDER_WEBURL=http://web-server:port
|
||||
MODULE_WEB_RENDER_INSTANCE=http://konabot-server:port
|
||||
```
|
||||
|
||||
替换 web-server 为你的前端服务器地址,konabot-server 为后端服务器地址,port 为端口号。
|
||||
4
justfile
Normal file
@ -0,0 +1,4 @@
|
||||
watch:
|
||||
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
||||
|
||||
|
||||
90
konabot/common/apis/ali_content_safety.py
Normal file
@ -0,0 +1,90 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from alibabacloud_green20220302.client import Client as AlibabaGreenClient
|
||||
from alibabacloud_green20220302.models import TextModerationPlusRequest
|
||||
from alibabacloud_tea_openapi.models import Config as AlibabaTeaConfig
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
import nonebot
|
||||
|
||||
|
||||
class AlibabaGreenPluginConfig(BaseModel):
|
||||
module_aligreen_enable: bool = False
|
||||
module_aligreen_access_key_id: str = ""
|
||||
module_aligreen_access_key_secret: str = ""
|
||||
module_aligreen_region_id: str = "cn-shenzhen"
|
||||
module_aligreen_endpoint: str = "green-cip.cn-shenzhen.aliyuncs.com"
|
||||
module_aligreen_service: str = "llm_query_moderation"
|
||||
|
||||
|
||||
class AlibabaGreen:
|
||||
_client: AlibabaGreenClient | None = None
|
||||
_config: AlibabaGreenPluginConfig | None = None
|
||||
|
||||
@staticmethod
|
||||
def get_client() -> AlibabaGreenClient:
|
||||
assert AlibabaGreen._client is not None
|
||||
return AlibabaGreen._client
|
||||
|
||||
@staticmethod
|
||||
def get_config() -> AlibabaGreenPluginConfig:
|
||||
assert AlibabaGreen._config is not None
|
||||
return AlibabaGreen._config
|
||||
|
||||
@staticmethod
|
||||
def init():
|
||||
config = nonebot.get_plugin_config(AlibabaGreenPluginConfig)
|
||||
AlibabaGreen._config = config
|
||||
if not config.module_aligreen_enable:
|
||||
logger.info("该环境未启用阿里内容审查,跳过初始化")
|
||||
return
|
||||
AlibabaGreen._client = AlibabaGreenClient(AlibabaTeaConfig(
|
||||
access_key_id=config.module_aligreen_access_key_id,
|
||||
access_key_secret=config.module_aligreen_access_key_secret,
|
||||
connect_timeout=10000,
|
||||
read_timeout=3000,
|
||||
region_id=config.module_aligreen_region_id,
|
||||
endpoint=config.module_aligreen_endpoint,
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _detect_sync(content: str) -> bool:
|
||||
if not AlibabaGreen.get_config().module_aligreen_enable:
|
||||
logger.debug("该环境未启用阿里内容审查,直接跳过")
|
||||
return True
|
||||
|
||||
client = AlibabaGreen.get_client()
|
||||
try:
|
||||
response = client.text_moderation_plus(TextModerationPlusRequest(
|
||||
service=AlibabaGreen.get_config().module_aligreen_service,
|
||||
service_parameters=json.dumps({
|
||||
"content": content,
|
||||
}),
|
||||
))
|
||||
if response.status_code == 200:
|
||||
result = response.body
|
||||
logger.info(f"检测违规内容 API 调用成功:{result}")
|
||||
risk_level: str = result.data.risk_level or "none"
|
||||
if risk_level == "high":
|
||||
return False
|
||||
return True
|
||||
logger.error(f"检测违规内容 API 调用失败:{response}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("检测违规内容 API 调用失败")
|
||||
logger.exception(e)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def detect(content: str) -> bool:
|
||||
return await asyncio.to_thread(AlibabaGreen._detect_sync, content)
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
@driver.on_startup
|
||||
async def _():
|
||||
AlibabaGreen.init()
|
||||
|
||||
@ -19,12 +19,12 @@ class DataManager(Generic[T]):
|
||||
if not self.fp.exists():
|
||||
return self.cls()
|
||||
try:
|
||||
return self.cls.model_validate_json(self.fp.read_text())
|
||||
return self.cls.model_validate_json(self.fp.read_text("utf-8"))
|
||||
except ValidationError:
|
||||
return self.cls()
|
||||
|
||||
def save(self, data: T):
|
||||
self.fp.write_text(data.model_dump_json())
|
||||
self.fp.write_text(data.model_dump_json(), "utf-8")
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_data(self):
|
||||
|
||||
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()
|
||||
|
||||
67
konabot/common/llm/__init__.py
Normal file
@ -0,0 +1,67 @@
|
||||
from typing import Any
|
||||
import openai
|
||||
|
||||
from loguru import logger
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionMessageParam
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from konabot.common.path import CONFIG_PATH
|
||||
|
||||
LLM_CONFIG_PATH = CONFIG_PATH / 'llm.json'
|
||||
|
||||
if not LLM_CONFIG_PATH.exists():
|
||||
LLM_CONFIG_PATH.write_text("{}")
|
||||
|
||||
|
||||
class LLMInfo(BaseModel):
|
||||
base_url: str
|
||||
api_key: str
|
||||
model_name: str
|
||||
|
||||
def get_openai_client(self):
|
||||
return openai.AsyncClient(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ChatCompletionMessageParam],
|
||||
timeout: float | None = 30.0,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatCompletionMessage:
|
||||
logger.info(f"调用 LLM: BASE_URL={self.base_url} MODEL_NAME={self.model_name}")
|
||||
completion: ChatCompletion = await self.get_openai_client().chat.completions.create(
|
||||
messages=messages,
|
||||
model=self.model_name,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
stream=False,
|
||||
**kwargs,
|
||||
)
|
||||
choice = completion.choices[0]
|
||||
logger.info(
|
||||
f"调用 LLM 完成: BASE_URL={self.base_url} MODEL_NAME={self.model_name} REASON={choice.finish_reason}"
|
||||
)
|
||||
return choice.message
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
llms: dict[str, LLMInfo] = Field(default_factory=dict)
|
||||
default_llm: str = "Qwen2.5-7B-Instruct"
|
||||
|
||||
|
||||
llm_config = LLMConfig.model_validate_json(LLM_CONFIG_PATH.read_text())
|
||||
|
||||
|
||||
def get_llm(llm_model: str | None = None):
|
||||
if llm_model is None:
|
||||
llm_model = llm_config.default_llm
|
||||
if llm_model not in llm_config.llms:
|
||||
if llm_config.default_llm in llm_config.llms:
|
||||
logger.warning(f"[LLM] 需求的 LLM 不存在,回退到默认模型 REQUIRED={llm_model}")
|
||||
return llm_config.llms[llm_config.default_llm]
|
||||
raise NotImplementedError("[LLM] LLM 未配置,该功能无法使用")
|
||||
return llm_config.llms[llm_model]
|
||||
|
||||
@ -51,13 +51,20 @@ class LongTaskTarget(BaseModel):
|
||||
target_id: str
|
||||
"沟通对象的 ID"
|
||||
|
||||
async def send_message(self, msg: UniMessage, at: bool = True) -> bool:
|
||||
@property
|
||||
def is_private_chat(self):
|
||||
return self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX)
|
||||
|
||||
async def send_message(self, msg: UniMessage | str, at: bool = True) -> bool:
|
||||
try:
|
||||
bot = nonebot.get_bot(self.self_id)
|
||||
except KeyError:
|
||||
logger.warning(f"试图访问了不存在的 Bot。ID={self.self_id}")
|
||||
return False
|
||||
|
||||
if isinstance(msg, str):
|
||||
msg = UniMessage.text(msg)
|
||||
|
||||
if self.platform == "qq":
|
||||
if not isinstance(bot, OBBot):
|
||||
logger.warning(
|
||||
@ -66,7 +73,7 @@ class LongTaskTarget(BaseModel):
|
||||
} BOT_CLASS={bot.__class__.__name__}"
|
||||
)
|
||||
return False
|
||||
if self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX):
|
||||
if self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX) or not self.channel_id.strip():
|
||||
# 私聊模式
|
||||
await bot.send_private_msg(
|
||||
user_id=int(self.target_id),
|
||||
@ -119,18 +126,18 @@ class LongTask(BaseModel):
|
||||
target: LongTaskTarget
|
||||
callback: str
|
||||
deadline: datetime.datetime
|
||||
canceled: bool = False
|
||||
|
||||
_aio_task: asynkio.Task | None = None
|
||||
|
||||
async def run(self):
|
||||
now = datetime.datetime.now()
|
||||
if self.deadline < now and not self.canceled:
|
||||
if self.deadline < now:
|
||||
await self._run_task()
|
||||
return
|
||||
await asynkio.sleep((self.deadline - now).total_seconds())
|
||||
if self.canceled:
|
||||
return
|
||||
async with longtask_data() as data:
|
||||
if self.uuid not in data.to_handle[self.callback]:
|
||||
return
|
||||
await self._run_task()
|
||||
|
||||
async def _run_task(self):
|
||||
@ -140,11 +147,7 @@ class LongTask(BaseModel):
|
||||
f"Callback {self.callback} 未曾被注册,但是被期待调用,已忽略"
|
||||
)
|
||||
async with longtask_data() as datafile:
|
||||
datafile.to_handle[self.callback] = [
|
||||
t
|
||||
for t in datafile.to_handle.get(self.callback, [])
|
||||
if t.uuid != self.uuid
|
||||
]
|
||||
del datafile.to_handle[self.callback][self.uuid]
|
||||
datafile.unhandled.setdefault(self.callback, []).append(self)
|
||||
|
||||
return
|
||||
@ -155,9 +158,7 @@ class LongTask(BaseModel):
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
async with longtask_data() as datafile:
|
||||
datafile.to_handle[self.callback] = [
|
||||
t for t in datafile.to_handle[self.callback] if t.uuid != self.uuid
|
||||
]
|
||||
del datafile.to_handle[self.callback][self.uuid]
|
||||
if not success:
|
||||
datafile.unhandled.setdefault(self.callback, []).append(self)
|
||||
logger.info(
|
||||
@ -181,7 +182,7 @@ class LongTask(BaseModel):
|
||||
|
||||
|
||||
class LongTaskModuleData(BaseModel):
|
||||
to_handle: dict[str, list[LongTask]]
|
||||
to_handle: dict[str, dict[str, LongTask]]
|
||||
unhandled: dict[str, list[LongTask]]
|
||||
|
||||
|
||||
@ -239,7 +240,7 @@ def handle_long_task(callback_id: str):
|
||||
|
||||
def _load_longtask_data() -> LongTaskModuleData:
|
||||
try:
|
||||
txt = LONGTASK_DATA_DIR.read_text()
|
||||
txt = LONGTASK_DATA_DIR.read_text("utf-8")
|
||||
return LongTaskModuleData.model_validate_json(txt)
|
||||
except (FileNotFoundError, ValidationError) as e:
|
||||
logger.info(f"取得 LongTask 数据时出现问题:{e}")
|
||||
@ -250,7 +251,7 @@ def _load_longtask_data() -> LongTaskModuleData:
|
||||
|
||||
|
||||
def _save_longtask_data(data: LongTaskModuleData):
|
||||
LONGTASK_DATA_DIR.write_text(data.model_dump_json())
|
||||
LONGTASK_DATA_DIR.write_text(data.model_dump_json(), "utf-8")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@ -279,7 +280,7 @@ async def create_longtask(
|
||||
await task.start()
|
||||
|
||||
async with longtask_data() as d:
|
||||
d.to_handle.setdefault(handler, []).append(task)
|
||||
d.to_handle.setdefault(handler, {})[task.uuid] = task
|
||||
|
||||
return task
|
||||
|
||||
@ -290,7 +291,7 @@ async def init_longtask():
|
||||
|
||||
async with longtask_data() as data:
|
||||
for v in data.to_handle.values():
|
||||
for t in v:
|
||||
for t in v.values():
|
||||
await t.start()
|
||||
counter += 1
|
||||
req.add(t.callback)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
@ -8,22 +9,44 @@ import nonebot
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.adapters import Bot, Event, Message
|
||||
from nonebot.adapters.discord import Bot as DiscordBot
|
||||
from nonebot.adapters.discord import MessageEvent as DiscordMessageEvent
|
||||
from nonebot.adapters.discord.config import Config as DiscordConfig
|
||||
from nonebot.adapters.onebot.v11 import Bot as OnebotV11Bot
|
||||
from nonebot.adapters.onebot.v11 import Message as OnebotV11Message
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
|
||||
import nonebot.params
|
||||
from nonebot_plugin_alconna import Image, RefNode, Reply, UniMessage
|
||||
from PIL import UnidentifiedImageError
|
||||
from pydantic import BaseModel
|
||||
from returns.result import Failure, Result, Success
|
||||
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
|
||||
discordConfig = nonebot.get_plugin_config(DiscordConfig)
|
||||
|
||||
|
||||
async def download_image_bytes(url: str) -> Result[bytes, str]:
|
||||
class ExtractImageConfig(BaseModel):
|
||||
module_extract_image_no_download: bool = False
|
||||
"""
|
||||
要不要算了,不下载了,直接爆炸算了,
|
||||
适用于一些比较奇怪的网络环境,无法从协议端下载文件
|
||||
"""
|
||||
|
||||
module_extract_image_target: str = './assets/img/other/boom.jpg'
|
||||
"""
|
||||
使用哪个图片呢
|
||||
"""
|
||||
|
||||
|
||||
module_config = nonebot.get_plugin_config(ExtractImageConfig)
|
||||
|
||||
|
||||
async def download_image_bytes(url: str, proxy: str | None = None) -> Result[bytes, str]:
|
||||
# if "/matcha/cache/" in url:
|
||||
# url = url.replace('127.0.0.1', '10.126.126.101')
|
||||
if module_config.module_extract_image_no_download:
|
||||
return Success(Path(module_config.module_extract_image_target).read_bytes())
|
||||
logger.debug(f"开始从 {url} 下载图片")
|
||||
async with httpx.AsyncClient() as c:
|
||||
async with httpx.AsyncClient(proxy=proxy) as c:
|
||||
try:
|
||||
response = await c.get(url)
|
||||
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
|
||||
@ -54,15 +77,22 @@ def bytes_to_pil(raw_data: bytes | BytesIO) -> Result[PIL.Image.Image, str]:
|
||||
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:
|
||||
raw_result = await download_image_bytes(image.url)
|
||||
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:
|
||||
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(
|
||||
@ -70,7 +100,7 @@ async def extract_image_from_qq_message(
|
||||
evt: OnebotV11MessageEvent,
|
||||
bot: OnebotV11Bot,
|
||||
allow_reply: bool = True,
|
||||
) -> Result[PIL.Image.Image, str]:
|
||||
) -> Result[bytes, str]:
|
||||
if allow_reply and (reply := evt.reply) is not None:
|
||||
return await extract_image_from_qq_message(
|
||||
reply.message,
|
||||
@ -102,18 +132,17 @@ async def extract_image_from_qq_message(
|
||||
url = seg.data.get("url")
|
||||
if url is None:
|
||||
return Failure("无法下载图片,可能有一些网络问题")
|
||||
data = await download_image_bytes(url)
|
||||
return data.bind(bytes_to_pil)
|
||||
return await download_image_bytes(url)
|
||||
|
||||
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
||||
|
||||
|
||||
async def extract_image_from_message(
|
||||
async def extract_image_data_from_message(
|
||||
msg: Message,
|
||||
evt: Event,
|
||||
bot: Bot,
|
||||
allow_reply: bool = True,
|
||||
) -> Result[PIL.Image.Image, str]:
|
||||
) -> Result[bytes, str]:
|
||||
if (
|
||||
isinstance(bot, OnebotV11Bot)
|
||||
and isinstance(msg, OnebotV11Message)
|
||||
@ -123,16 +152,24 @@ async def extract_image_from_message(
|
||||
logger.debug('获取图片的路径 Fallback 到 QQ 模块')
|
||||
return await extract_image_from_qq_message(msg, evt, bot, allow_reply)
|
||||
|
||||
if isinstance(evt, DiscordMessageEvent):
|
||||
logger.debug('获取图片的路径方式走 Discord')
|
||||
for a in evt.attachments:
|
||||
if "image/" not in a.content_type:
|
||||
continue
|
||||
url = a.proxy_url
|
||||
return await download_image_bytes(url, discordConfig.discord_proxy)
|
||||
|
||||
for seg in UniMessage.of(msg, bot):
|
||||
logger.info(seg)
|
||||
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:
|
||||
msg2 = seg.msg
|
||||
logger.debug(f"深入搜索引用的消息:{msg2}")
|
||||
if msg2 is None or isinstance(msg2, str):
|
||||
continue
|
||||
return await extract_image_from_message(msg2, evt, bot, False)
|
||||
return await extract_image_data_from_message(msg2, evt, bot, False)
|
||||
elif isinstance(seg, RefNode) and allow_reply:
|
||||
if isinstance(bot, DiscordBot):
|
||||
return Failure("暂时不支持在 Discord 中通过引用的方式获取图片")
|
||||
@ -141,12 +178,12 @@ async def extract_image_from_message(
|
||||
return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
|
||||
|
||||
|
||||
async def _ext_img(
|
||||
async def _ext_img_data(
|
||||
evt: Event,
|
||||
bot: Bot,
|
||||
matcher: Matcher,
|
||||
) -> PIL.Image.Image | None:
|
||||
match await extract_image_from_message(evt.get_message(), evt, bot):
|
||||
) -> bytes | None:
|
||||
match await extract_image_data_from_message(evt.get_message(), evt, bot):
|
||||
case Success(img):
|
||||
return img
|
||||
case Failure(err):
|
||||
@ -156,4 +193,20 @@ async def _ext_img(
|
||||
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)]
|
||||
|
||||
33
konabot/common/nb/qq_broadcast.py
Normal file
@ -0,0 +1,33 @@
|
||||
from typing import Any, cast
|
||||
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBBot
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
|
||||
async def qq_broadcast(groups: list[str], msg: UniMessage[Any] | str):
|
||||
if isinstance(msg, str):
|
||||
msg = UniMessage.text(msg)
|
||||
bots: dict[str, OBBot] = {}
|
||||
|
||||
# group_id -> bot_id
|
||||
availabilities: dict[str, str] = {}
|
||||
|
||||
for bot_id, bot in nonebot.get_bots().items():
|
||||
if not isinstance(bot, OBBot):
|
||||
continue
|
||||
bots[bot_id] = bot
|
||||
gl = await bot.get_group_list()
|
||||
for g in gl:
|
||||
gid = str(g.get("group_id", -1))
|
||||
if gid in groups:
|
||||
availabilities[gid] = bot_id
|
||||
|
||||
for group in groups:
|
||||
if group in availabilities:
|
||||
bot = bots[availabilities[group]]
|
||||
await bot.send_group_msg(
|
||||
group_id=int(group),
|
||||
message=cast(Any, await msg.export(bot)),
|
||||
auto_escape=False,
|
||||
)
|
||||
76
konabot/common/pager.py
Normal file
@ -0,0 +1,76 @@
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import Any, Callable
|
||||
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
|
||||
@dataclass
|
||||
class PagerQuery:
|
||||
page_index: int
|
||||
page_size: int
|
||||
|
||||
def apply[T](self, ls: list[T]) -> "PagerResult[T]":
|
||||
if self.page_size <= 0:
|
||||
return PagerResult(
|
||||
success=False,
|
||||
message="每页元素数量应该大于 0",
|
||||
data=[],
|
||||
page_count=-1,
|
||||
query=self,
|
||||
)
|
||||
page_count = ceil(len(ls) / self.page_size)
|
||||
if self.page_index <= 0 or self.page_size <= 0:
|
||||
return PagerResult(
|
||||
success=False,
|
||||
message="页数必须大于 0",
|
||||
data=[],
|
||||
page_count=page_count,
|
||||
query=self,
|
||||
)
|
||||
data = ls[(self.page_index - 1) * self.page_size: self.page_index * self.page_size]
|
||||
if len(data) > 0:
|
||||
return PagerResult(
|
||||
success=True,
|
||||
message="",
|
||||
data=data,
|
||||
page_count=page_count,
|
||||
query=self,
|
||||
)
|
||||
return PagerResult(
|
||||
success=False,
|
||||
message="指定的页数超过最大页数",
|
||||
data=data,
|
||||
page_count=page_count,
|
||||
query=self,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PagerResult[T]:
|
||||
data: list[T]
|
||||
success: bool
|
||||
message: str
|
||||
page_count: int
|
||||
query: PagerQuery
|
||||
|
||||
def to_unimessage(
|
||||
self,
|
||||
formatter: Callable[[T], str | UniMessage[Any]] = str,
|
||||
title: str = '查询结果',
|
||||
list_indicator: str = '- ',
|
||||
) -> UniMessage[Any]:
|
||||
msg = UniMessage.text(f'===== {title} =====\n\n')
|
||||
|
||||
if not self.success:
|
||||
msg = msg.text(f'⚠️ {self.message}\n')
|
||||
else:
|
||||
for obj in self.data:
|
||||
msg = msg.text(list_indicator)
|
||||
msg += formatter(obj)
|
||||
msg += '\n'
|
||||
|
||||
msg = msg.text(f'\n===== 第 {self.query.page_index} 页,共 {self.page_count} 页 =====')
|
||||
return msg
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ FONTS_PATH = ASSETS_PATH / "fonts"
|
||||
SRC_PATH = Path(__file__).resolve().parent.parent
|
||||
DATA_PATH = SRC_PATH.parent / "data"
|
||||
LOG_PATH = DATA_PATH / "logs"
|
||||
CONFIG_PATH = DATA_PATH / "config"
|
||||
|
||||
DOCS_PATH = SRC_PATH / "docs"
|
||||
DOCS_PATH_MAN1 = DOCS_PATH / "user"
|
||||
@ -19,3 +20,5 @@ if not DATA_PATH.exists():
|
||||
if not LOG_PATH.exists():
|
||||
LOG_PATH.mkdir()
|
||||
|
||||
CONFIG_PATH.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
3
konabot/common/ptimeparse/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# 已废弃
|
||||
|
||||
坏枪用简单的 LLM + 提示词工程,完成了这 200 块的 `qwen3-coder-plus` 都搞不定的 nb 功能
|
||||
58
konabot/common/ptimeparse/__init__.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""
|
||||
Professional time parsing module for Chinese and English time expressions.
|
||||
|
||||
This module provides a robust parser for natural language time expressions,
|
||||
supporting both Chinese and English formats with proper whitespace handling.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .expression import TimeExpression
|
||||
|
||||
|
||||
def parse(text: str, now: Optional[datetime.datetime] = None) -> datetime.datetime:
|
||||
"""
|
||||
Parse a time expression and return a datetime object.
|
||||
|
||||
Args:
|
||||
text: The time expression to parse
|
||||
now: The reference time (defaults to current time)
|
||||
|
||||
Returns:
|
||||
A datetime object representing the parsed time
|
||||
|
||||
Raises:
|
||||
TokenUnhandledException: If the input cannot be parsed
|
||||
"""
|
||||
return TimeExpression.parse(text, now)
|
||||
|
||||
|
||||
class Parser:
|
||||
"""
|
||||
Parser for time expressions with backward compatibility.
|
||||
|
||||
Maintains the original interface:
|
||||
>>> parser = Parser()
|
||||
>>> result = parser.parse("10分钟后")
|
||||
"""
|
||||
|
||||
def __init__(self, now: Optional[datetime.datetime] = None):
|
||||
self.now = now or datetime.datetime.now()
|
||||
|
||||
def parse(self, text: str) -> datetime.datetime:
|
||||
"""
|
||||
Parse a time expression and return a datetime object.
|
||||
This maintains backward compatibility with the original interface.
|
||||
|
||||
Args:
|
||||
text: The time expression to parse
|
||||
|
||||
Returns:
|
||||
A datetime object representing the parsed time
|
||||
|
||||
Raises:
|
||||
TokenUnhandledException: If the input cannot be parsed
|
||||
"""
|
||||
return TimeExpression.parse(text, self.now)
|
||||
|
||||
133
konabot/common/ptimeparse/chinese_number.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""
|
||||
Chinese number parser for the time expression parser.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class ChineseNumberParser:
|
||||
"""Parser for Chinese numbers."""
|
||||
|
||||
def __init__(self):
|
||||
self.digits = {"零": 0, "一": 1, "二": 2, "三": 3, "四": 4,
|
||||
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
|
||||
self.units = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000}
|
||||
|
||||
def digest(self, text: str) -> Tuple[str, int]:
|
||||
"""
|
||||
Parse a Chinese number from the beginning of text and return the rest and the parsed number.
|
||||
|
||||
Args:
|
||||
text: Text that may start with a Chinese number
|
||||
|
||||
Returns:
|
||||
Tuple of (remaining_text, parsed_number)
|
||||
"""
|
||||
if not text:
|
||||
return text, 0
|
||||
|
||||
# Handle "两" at start
|
||||
if text.startswith("两"):
|
||||
# Check if "两" is followed by a time unit
|
||||
# Look ahead to see if we have a valid pattern like "两小时", "两分钟", etc.
|
||||
if len(text) >= 2:
|
||||
# Check for time units that start with the second character
|
||||
time_units = ["小时", "分钟", "秒"]
|
||||
for unit in time_units:
|
||||
if text[1:].startswith(unit):
|
||||
# Return the text starting from the time unit, not after it
|
||||
# The parser will handle the time unit in the next step
|
||||
return text[1:], 2
|
||||
# Check for single character time units
|
||||
next_char = text[1]
|
||||
if next_char in "时分秒":
|
||||
return text[1:], 2
|
||||
# Check for Chinese number units
|
||||
if next_char in "十百千万亿":
|
||||
# This will be handled by the normal parsing below
|
||||
pass
|
||||
# If "两" is at the end of string, treat it as standalone
|
||||
elif len(text) == 1:
|
||||
return "", 2
|
||||
# Also accept "两" followed by whitespace and then time units
|
||||
elif next_char.isspace():
|
||||
# Check if after whitespace we have time units
|
||||
rest_after_space = text[2:].lstrip()
|
||||
for unit in time_units:
|
||||
if rest_after_space.startswith(unit):
|
||||
# Return the text starting from the time unit
|
||||
space_len = len(text[2:]) - len(rest_after_space)
|
||||
return text[2+space_len:], 2
|
||||
# Check single character time units after whitespace
|
||||
if rest_after_space and rest_after_space[0] in "时分秒":
|
||||
return text[2:], 2
|
||||
else:
|
||||
# Just "两" by itself
|
||||
return "", 2
|
||||
|
||||
s = "零一二三四五六七八九"
|
||||
i = 0
|
||||
while i < len(text) and text[i] in s + "十百千万亿":
|
||||
i += 1
|
||||
if i == 0:
|
||||
return text, 0
|
||||
num_str = text[:i]
|
||||
rest = text[i:]
|
||||
|
||||
return rest, self.parse(num_str)
|
||||
|
||||
def parse(self, text: str) -> int:
|
||||
"""
|
||||
Parse a Chinese number string and return its integer value.
|
||||
|
||||
Args:
|
||||
text: Chinese number string
|
||||
|
||||
Returns:
|
||||
Integer value of the Chinese number
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
if text == "零":
|
||||
return 0
|
||||
if text == "两":
|
||||
return 2
|
||||
|
||||
# Handle special case for "十"
|
||||
if text == "十":
|
||||
return 10
|
||||
|
||||
# Handle numbers with "亿"
|
||||
if "亿" in text:
|
||||
parts = text.split("亿", 1)
|
||||
a, b = parts[0], parts[1]
|
||||
return self.parse(a) * 100000000 + self.parse(b)
|
||||
|
||||
# Handle numbers with "万"
|
||||
if "万" in text:
|
||||
parts = text.split("万", 1)
|
||||
a, b = parts[0], parts[1]
|
||||
return self.parse(a) * 10000 + self.parse(b)
|
||||
|
||||
# Handle remaining numbers
|
||||
result = 0
|
||||
temp = 0
|
||||
|
||||
for char in text:
|
||||
if char == "零":
|
||||
continue
|
||||
elif char == "两":
|
||||
temp = 2
|
||||
elif char in self.digits:
|
||||
temp = self.digits[char]
|
||||
elif char in self.units:
|
||||
unit = self.units[char]
|
||||
if unit == 10 and temp == 0:
|
||||
# Special case for numbers like "十三"
|
||||
temp = 1
|
||||
result += temp * unit
|
||||
temp = 0
|
||||
|
||||
result += temp
|
||||
return result
|
||||
11
konabot/common/ptimeparse/err.py
Normal file
@ -0,0 +1,11 @@
|
||||
class PTimeParseException(Exception):
|
||||
...
|
||||
|
||||
class TokenUnhandledException(PTimeParseException):
|
||||
...
|
||||
|
||||
class MultipleSpecificationException(PTimeParseException):
|
||||
...
|
||||
|
||||
class OutOfRangeSpecificationException(PTimeParseException):
|
||||
...
|
||||
63
konabot/common/ptimeparse/expression.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""
|
||||
Main time expression parser class that integrates all components.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .lexer import Lexer
|
||||
from .parser import Parser
|
||||
from .semantic import SemanticAnalyzer
|
||||
from .ptime_ast import TimeExpressionNode
|
||||
from .err import TokenUnhandledException
|
||||
|
||||
|
||||
class TimeExpression:
|
||||
"""Main class for parsing time expressions."""
|
||||
|
||||
def __init__(self, text: str, now: Optional[datetime.datetime] = None):
|
||||
self.text = text.strip()
|
||||
self.now = now or datetime.datetime.now()
|
||||
|
||||
if not self.text:
|
||||
raise TokenUnhandledException("Empty input")
|
||||
|
||||
# Initialize components
|
||||
self.lexer = Lexer(self.text, self.now)
|
||||
self.parser = Parser(self.text, self.now)
|
||||
self.semantic_analyzer = SemanticAnalyzer(self.now)
|
||||
|
||||
# Parse the expression
|
||||
self.ast = self._parse()
|
||||
|
||||
def _parse(self) -> TimeExpressionNode:
|
||||
"""Parse the time expression and return the AST."""
|
||||
try:
|
||||
return self.parser.parse()
|
||||
except Exception as e:
|
||||
raise TokenUnhandledException(f"Failed to parse '{self.text}': {str(e)}")
|
||||
|
||||
def evaluate(self) -> datetime.datetime:
|
||||
"""Evaluate the time expression and return the datetime."""
|
||||
try:
|
||||
return self.semantic_analyzer.evaluate(self.ast)
|
||||
except Exception as e:
|
||||
raise TokenUnhandledException(f"Failed to evaluate '{self.text}': {str(e)}")
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str, now: Optional[datetime.datetime] = None) -> datetime.datetime:
|
||||
"""
|
||||
Parse a time expression and return a datetime object.
|
||||
|
||||
Args:
|
||||
text: The time expression to parse
|
||||
now: The reference time (defaults to current time)
|
||||
|
||||
Returns:
|
||||
A datetime object representing the parsed time
|
||||
|
||||
Raises:
|
||||
TokenUnhandledException: If the input cannot be parsed
|
||||
"""
|
||||
expression = cls(text, now)
|
||||
return expression.evaluate()
|
||||
225
konabot/common/ptimeparse/lexer.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""
|
||||
Lexical analyzer for time expressions.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Iterator, Optional
|
||||
import datetime
|
||||
|
||||
from .ptime_token import Token, TokenType
|
||||
from .chinese_number import ChineseNumberParser
|
||||
|
||||
|
||||
class Lexer:
|
||||
"""Lexical analyzer for time expressions."""
|
||||
|
||||
def __init__(self, text: str, now: Optional[datetime.datetime] = None):
|
||||
self.text = text
|
||||
self.pos = 0
|
||||
self.current_char = self.text[self.pos] if self.text else None
|
||||
self.now = now or datetime.datetime.now()
|
||||
self.chinese_parser = ChineseNumberParser()
|
||||
|
||||
# Define token patterns
|
||||
self.token_patterns = [
|
||||
# Whitespace
|
||||
(r'^\s+', TokenType.WHITESPACE),
|
||||
|
||||
# Time separators
|
||||
(r'^:', TokenType.TIME_SEPARATOR),
|
||||
(r'^点', TokenType.TIME_SEPARATOR),
|
||||
(r'^时', TokenType.TIME_SEPARATOR),
|
||||
(r'^分', TokenType.TIME_SEPARATOR),
|
||||
(r'^秒', TokenType.TIME_SEPARATOR),
|
||||
|
||||
# Special time markers
|
||||
(r'^半', TokenType.HALF),
|
||||
(r'^一刻', TokenType.QUARTER),
|
||||
(r'^整', TokenType.ZHENG),
|
||||
(r'^钟', TokenType.ZHONG),
|
||||
|
||||
# Period indicators (must come before relative time patterns to avoid conflicts)
|
||||
(r'^(上午|早晨|早上|清晨|早(?!\d))', TokenType.PERIOD_AM),
|
||||
(r'^(中午|下午|晚上|晚(?!\d)|凌晨|午夜)', TokenType.PERIOD_PM),
|
||||
|
||||
# Week scope (more specific patterns first)
|
||||
(r'^本周', TokenType.WEEK_SCOPE_CURRENT),
|
||||
(r'^上周', TokenType.WEEK_SCOPE_LAST),
|
||||
(r'^下周', TokenType.WEEK_SCOPE_NEXT),
|
||||
|
||||
# Relative directions
|
||||
(r'^(后|以后|之后)', TokenType.RELATIVE_DIRECTION_FORWARD),
|
||||
(r'^(前|以前|之前)', TokenType.RELATIVE_DIRECTION_BACKWARD),
|
||||
|
||||
# Extended relative time
|
||||
(r'^明年', TokenType.RELATIVE_NEXT),
|
||||
(r'^去年', TokenType.RELATIVE_LAST),
|
||||
(r'^今年', TokenType.RELATIVE_THIS),
|
||||
(r'^下(?![午年月周])', TokenType.RELATIVE_NEXT),
|
||||
(r'^(上|去)(?![午年月周])', TokenType.RELATIVE_LAST),
|
||||
(r'^这', TokenType.RELATIVE_THIS),
|
||||
(r'^本(?![周月年])', TokenType.RELATIVE_THIS), # Match "本" but not "本周", "本月", "本年"
|
||||
|
||||
# Week scope (fallback for standalone terms)
|
||||
(r'^本', TokenType.WEEK_SCOPE_CURRENT),
|
||||
(r'^上', TokenType.WEEK_SCOPE_LAST),
|
||||
(r'^下(?![午年月周])', TokenType.WEEK_SCOPE_NEXT),
|
||||
|
||||
# Week days (order matters - longer patterns first)
|
||||
(r'^周一', TokenType.WEEKDAY_MONDAY),
|
||||
(r'^周二', TokenType.WEEKDAY_TUESDAY),
|
||||
(r'^周三', TokenType.WEEKDAY_WEDNESDAY),
|
||||
(r'^周四', TokenType.WEEKDAY_THURSDAY),
|
||||
(r'^周五', TokenType.WEEKDAY_FRIDAY),
|
||||
(r'^周六', TokenType.WEEKDAY_SATURDAY),
|
||||
(r'^周日', TokenType.WEEKDAY_SUNDAY),
|
||||
# Single character weekdays should be matched after numbers
|
||||
# (r'^一', TokenType.WEEKDAY_MONDAY),
|
||||
# (r'^二', TokenType.WEEKDAY_TUESDAY),
|
||||
# (r'^三', TokenType.WEEKDAY_WEDNESDAY),
|
||||
# (r'^四', TokenType.WEEKDAY_THURSDAY),
|
||||
# (r'^五', TokenType.WEEKDAY_FRIDAY),
|
||||
# (r'^六', TokenType.WEEKDAY_SATURDAY),
|
||||
# (r'^日', TokenType.WEEKDAY_SUNDAY),
|
||||
|
||||
# Student-friendly time expressions
|
||||
(r'^早(?=\d)', TokenType.EARLY_MORNING),
|
||||
(r'^晚(?=\d)', TokenType.LATE_NIGHT),
|
||||
|
||||
# Relative today variants
|
||||
(r'^今晚上', TokenType.RELATIVE_TODAY),
|
||||
(r'^今晚', TokenType.RELATIVE_TODAY),
|
||||
(r'^今早', TokenType.RELATIVE_TODAY),
|
||||
(r'^今天早上', TokenType.RELATIVE_TODAY),
|
||||
(r'^今天早晨', TokenType.RELATIVE_TODAY),
|
||||
(r'^今天上午', TokenType.RELATIVE_TODAY),
|
||||
(r'^今天下午', TokenType.RELATIVE_TODAY),
|
||||
(r'^今天晚上', TokenType.RELATIVE_TODAY),
|
||||
(r'^今天', TokenType.RELATIVE_TODAY),
|
||||
|
||||
# Relative days
|
||||
(r'^明天', TokenType.RELATIVE_TOMORROW),
|
||||
(r'^后天', TokenType.RELATIVE_DAY_AFTER_TOMORROW),
|
||||
(r'^大后天', TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW),
|
||||
(r'^昨天', TokenType.RELATIVE_YESTERDAY),
|
||||
(r'^前天', TokenType.RELATIVE_DAY_BEFORE_YESTERDAY),
|
||||
(r'^大前天', TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY),
|
||||
|
||||
# Digits
|
||||
(r'^\d+', TokenType.INTEGER),
|
||||
|
||||
# Time units (must come after date separators to avoid conflicts)
|
||||
(r'^年(?![月日号])', TokenType.YEAR),
|
||||
(r'^月(?![日号])', TokenType.MONTH),
|
||||
(r'^[日号](?![月年])', TokenType.DAY),
|
||||
(r'^天', TokenType.DAY),
|
||||
(r'^周', TokenType.WEEK),
|
||||
(r'^小时', TokenType.HOUR),
|
||||
(r'^分钟', TokenType.MINUTE),
|
||||
(r'^秒', TokenType.SECOND),
|
||||
|
||||
# Date separators (fallback patterns)
|
||||
(r'^年', TokenType.DATE_SEPARATOR),
|
||||
(r'^月', TokenType.DATE_SEPARATOR),
|
||||
(r'^[日号]', TokenType.DATE_SEPARATOR),
|
||||
(r'^[-/]', TokenType.DATE_SEPARATOR),
|
||||
]
|
||||
|
||||
def advance(self):
|
||||
"""Advance the position pointer and set the current character."""
|
||||
self.pos += 1
|
||||
if self.pos >= len(self.text):
|
||||
self.current_char = None
|
||||
else:
|
||||
self.current_char = self.text[self.pos]
|
||||
|
||||
def skip_whitespace(self):
|
||||
"""Skip whitespace characters."""
|
||||
while self.current_char is not None and self.current_char.isspace():
|
||||
self.advance()
|
||||
|
||||
def integer(self) -> int:
|
||||
"""Parse an integer from the input."""
|
||||
result = ''
|
||||
while self.current_char is not None and self.current_char.isdigit():
|
||||
result += self.current_char
|
||||
self.advance()
|
||||
return int(result)
|
||||
|
||||
def chinese_number(self) -> int:
|
||||
"""Parse a Chinese number from the input."""
|
||||
# Find the longest prefix that can be parsed as a Chinese number
|
||||
for i in range(len(self.text) - self.pos, 0, -1):
|
||||
prefix = self.text[self.pos:self.pos + i]
|
||||
try:
|
||||
# Use digest to get both the remaining text and the parsed value
|
||||
remaining, value = self.chinese_parser.digest(prefix)
|
||||
# Check if we actually consumed part of the prefix
|
||||
consumed_length = len(prefix) - len(remaining)
|
||||
if consumed_length > 0:
|
||||
# Advance position by the length of the consumed text
|
||||
for _ in range(consumed_length):
|
||||
self.advance()
|
||||
return value
|
||||
except ValueError:
|
||||
continue
|
||||
# If no Chinese number found, just return 0
|
||||
return 0
|
||||
|
||||
def get_next_token(self) -> Token:
|
||||
"""Lexical analyzer that breaks the sentence into tokens."""
|
||||
while self.current_char is not None:
|
||||
# Skip whitespace
|
||||
if self.current_char.isspace():
|
||||
self.skip_whitespace()
|
||||
continue
|
||||
|
||||
# Try to match each pattern
|
||||
text_remaining = self.text[self.pos:]
|
||||
for pattern, token_type in self.token_patterns:
|
||||
match = re.match(pattern, text_remaining)
|
||||
if match:
|
||||
value = match.group(0)
|
||||
position = self.pos
|
||||
|
||||
# Advance position
|
||||
for _ in range(len(value)):
|
||||
self.advance()
|
||||
|
||||
# Special handling for some tokens
|
||||
if token_type == TokenType.INTEGER:
|
||||
value = int(value)
|
||||
elif token_type == TokenType.RELATIVE_TODAY and value in [
|
||||
"今早上", "今天早上", "今天早晨", "今天上午"
|
||||
]:
|
||||
token_type = TokenType.PERIOD_AM
|
||||
elif token_type == TokenType.RELATIVE_TODAY and value in [
|
||||
"今晚上", "今天下午", "今天晚上"
|
||||
]:
|
||||
token_type = TokenType.PERIOD_PM
|
||||
|
||||
return Token(token_type, value, position)
|
||||
|
||||
# Try to parse Chinese numbers
|
||||
chinese_start_pos = self.pos
|
||||
try:
|
||||
chinese_value = self.chinese_number()
|
||||
if chinese_value > 0:
|
||||
# We successfully parsed a Chinese number
|
||||
return Token(TokenType.CHINESE_NUMBER, chinese_value, chinese_start_pos)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If no pattern matches, skip the character and continue
|
||||
self.advance()
|
||||
|
||||
# End of file
|
||||
return Token(TokenType.EOF, None, self.pos)
|
||||
|
||||
def tokenize(self) -> Iterator[Token]:
|
||||
"""Generate all tokens from the input."""
|
||||
while True:
|
||||
token = self.get_next_token()
|
||||
yield token
|
||||
if token.type == TokenType.EOF:
|
||||
break
|
||||
846
konabot/common/ptimeparse/parser.py
Normal file
@ -0,0 +1,846 @@
|
||||
"""
|
||||
Parser for time expressions that builds an Abstract Syntax Tree (AST).
|
||||
"""
|
||||
|
||||
from typing import Iterator, Optional, List
|
||||
import datetime
|
||||
|
||||
from .ptime_token import Token, TokenType
|
||||
from .ptime_ast import (
|
||||
ASTNode, NumberNode, DateNode, TimeNode,
|
||||
RelativeDateNode, RelativeTimeNode, WeekdayNode, TimeExpressionNode
|
||||
)
|
||||
from .lexer import Lexer
|
||||
|
||||
|
||||
class ParserError(Exception):
|
||||
"""Exception raised for parser errors."""
|
||||
pass
|
||||
|
||||
|
||||
class Parser:
|
||||
"""Parser for time expressions that builds an AST."""
|
||||
|
||||
def __init__(self, text: str, now: Optional[datetime.datetime] = None):
|
||||
self.lexer = Lexer(text, now)
|
||||
self.tokens: List[Token] = list(self.lexer.tokenize())
|
||||
self.pos = 0
|
||||
self.now = now or datetime.datetime.now()
|
||||
|
||||
@property
|
||||
def current_token(self) -> Token:
|
||||
"""Get the current token."""
|
||||
if self.pos < len(self.tokens):
|
||||
return self.tokens[self.pos]
|
||||
return Token(TokenType.EOF, None, len(self.tokens))
|
||||
|
||||
def eat(self, token_type: TokenType) -> Token:
|
||||
"""Consume a token of the expected type."""
|
||||
if self.current_token.type == token_type:
|
||||
token = self.current_token
|
||||
self.pos += 1
|
||||
return token
|
||||
else:
|
||||
raise ParserError(
|
||||
f"Expected token {token_type}, got {self.current_token.type} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
|
||||
def peek(self, offset: int = 1) -> Token:
|
||||
"""Look ahead at the next token without consuming it."""
|
||||
next_pos = self.pos + offset
|
||||
if next_pos < len(self.tokens):
|
||||
return self.tokens[next_pos]
|
||||
return Token(TokenType.EOF, None, len(self.tokens))
|
||||
|
||||
def parse_number(self) -> NumberNode:
|
||||
"""Parse a number (integer or Chinese number)."""
|
||||
token = self.current_token
|
||||
if token.type == TokenType.INTEGER:
|
||||
self.eat(TokenType.INTEGER)
|
||||
return NumberNode(value=token.value)
|
||||
elif token.type == TokenType.CHINESE_NUMBER:
|
||||
self.eat(TokenType.CHINESE_NUMBER)
|
||||
return NumberNode(value=token.value)
|
||||
else:
|
||||
raise ParserError(
|
||||
f"Expected number, got {token.type} at position {token.position}"
|
||||
)
|
||||
|
||||
def parse_date(self) -> DateNode:
|
||||
"""Parse a date specification."""
|
||||
year_node = None
|
||||
month_node = None
|
||||
day_node = None
|
||||
|
||||
# Try YYYY-MM-DD or YYYY/MM/DD format
|
||||
if (self.current_token.type == TokenType.INTEGER and
|
||||
self.peek().type == TokenType.DATE_SEPARATOR and
|
||||
self.peek().value in ['-', '/'] and
|
||||
self.peek(2).type == TokenType.INTEGER and
|
||||
self.peek(3).type == TokenType.DATE_SEPARATOR and
|
||||
self.peek(3).value in ['-', '/'] and
|
||||
self.peek(4).type == TokenType.INTEGER):
|
||||
|
||||
year_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
separator1 = self.eat(TokenType.DATE_SEPARATOR).value
|
||||
|
||||
month_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
|
||||
separator2 = self.eat(TokenType.DATE_SEPARATOR).value
|
||||
|
||||
day_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
|
||||
year_node = NumberNode(value=year_token.value)
|
||||
month_node = NumberNode(value=month_token.value)
|
||||
day_node = NumberNode(value=day_token.value)
|
||||
|
||||
return DateNode(year=year_node, month=month_node, day=day_node)
|
||||
|
||||
# Try YYYY年MM月DD[日号] format
|
||||
if (self.current_token.type == TokenType.INTEGER and
|
||||
self.peek().type in [TokenType.DATE_SEPARATOR, TokenType.YEAR] and
|
||||
self.peek(2).type == TokenType.INTEGER and
|
||||
self.peek(3).type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
|
||||
self.peek(4).type == TokenType.INTEGER):
|
||||
|
||||
year_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
self.eat(self.current_token.type) # 年 (could be DATE_SEPARATOR or YEAR)
|
||||
|
||||
month_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
self.eat(self.current_token.type) # 月 (could be DATE_SEPARATOR or MONTH)
|
||||
|
||||
day_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
# Optional 日 or 号
|
||||
if self.current_token.type in [TokenType.DATE_SEPARATOR, TokenType.DAY]:
|
||||
self.eat(self.current_token.type)
|
||||
|
||||
year_node = NumberNode(value=year_token.value)
|
||||
month_node = NumberNode(value=month_token.value)
|
||||
day_node = NumberNode(value=day_token.value)
|
||||
|
||||
return DateNode(year=year_node, month=month_node, day=day_node)
|
||||
|
||||
# Try MM月DD[日号] format (without year)
|
||||
if (self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.peek().type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
|
||||
self.peek().value == '月' and
|
||||
self.peek(2).type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
|
||||
|
||||
month_token = self.current_token
|
||||
self.eat(month_token.type)
|
||||
self.eat(self.current_token.type) # 月 (could be DATE_SEPARATOR or MONTH)
|
||||
|
||||
day_token = self.current_token
|
||||
self.eat(day_token.type)
|
||||
# Optional 日 or 号
|
||||
if self.current_token.type in [TokenType.DATE_SEPARATOR, TokenType.DAY]:
|
||||
self.eat(self.current_token.type)
|
||||
|
||||
month_node = NumberNode(value=month_token.value)
|
||||
day_node = NumberNode(value=day_token.value)
|
||||
|
||||
return DateNode(year=None, month=month_node, day=day_node)
|
||||
|
||||
# Try Chinese MM月DD[日号] format
|
||||
if (self.current_token.type == TokenType.CHINESE_NUMBER and
|
||||
self.peek().type == TokenType.DATE_SEPARATOR and
|
||||
self.peek().value == '月' and
|
||||
self.peek(2).type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
|
||||
|
||||
month_token = self.current_token
|
||||
self.eat(TokenType.CHINESE_NUMBER)
|
||||
self.eat(TokenType.DATE_SEPARATOR) # 月
|
||||
|
||||
day_token = self.current_token
|
||||
self.eat(day_token.type)
|
||||
# Optional 日 or 号
|
||||
if self.current_token.type == TokenType.DATE_SEPARATOR:
|
||||
self.eat(TokenType.DATE_SEPARATOR)
|
||||
|
||||
month_node = NumberNode(value=month_token.value)
|
||||
day_node = NumberNode(value=day_token.value)
|
||||
|
||||
return DateNode(year=None, month=month_node, day=day_node)
|
||||
|
||||
raise ParserError(
|
||||
f"Unable to parse date at position {self.current_token.position}"
|
||||
)
|
||||
|
||||
def parse_time(self) -> TimeNode:
|
||||
"""Parse a time specification."""
|
||||
hour_node = None
|
||||
minute_node = None
|
||||
second_node = None
|
||||
is_24hour = False
|
||||
period = None
|
||||
|
||||
# Try HH:MM format
|
||||
if (self.current_token.type == TokenType.INTEGER and
|
||||
self.peek().type == TokenType.TIME_SEPARATOR and
|
||||
self.peek().value == ':'):
|
||||
|
||||
hour_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
self.eat(TokenType.TIME_SEPARATOR) # :
|
||||
|
||||
minute_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
|
||||
hour_node = NumberNode(value=hour_token.value)
|
||||
minute_node = NumberNode(value=minute_token.value)
|
||||
is_24hour = True # HH:MM is always interpreted as 24-hour
|
||||
|
||||
# Optional :SS
|
||||
if (self.current_token.type == TokenType.TIME_SEPARATOR and
|
||||
self.peek().type == TokenType.INTEGER):
|
||||
|
||||
self.eat(TokenType.TIME_SEPARATOR) # :
|
||||
second_token = self.current_token
|
||||
self.eat(TokenType.INTEGER)
|
||||
second_node = NumberNode(value=second_token.value)
|
||||
|
||||
return TimeNode(
|
||||
hour=hour_node,
|
||||
minute=minute_node,
|
||||
second=second_node,
|
||||
is_24hour=is_24hour,
|
||||
period=period
|
||||
)
|
||||
|
||||
# Try Chinese time format (X点X分)
|
||||
# First check for period indicators
|
||||
period = None
|
||||
if self.current_token.type in [TokenType.PERIOD_AM, TokenType.PERIOD_PM]:
|
||||
if self.current_token.type == TokenType.PERIOD_AM:
|
||||
period = "AM"
|
||||
else:
|
||||
period = "PM"
|
||||
self.eat(self.current_token.type)
|
||||
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER, TokenType.EARLY_MORNING, TokenType.LATE_NIGHT]:
|
||||
if self.current_token.type == TokenType.EARLY_MORNING:
|
||||
self.eat(TokenType.EARLY_MORNING)
|
||||
is_24hour = True
|
||||
period = "AM"
|
||||
|
||||
# Expect a number next
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
|
||||
hour_token = self.current_token
|
||||
self.eat(hour_token.type)
|
||||
hour_node = NumberNode(value=hour_token.value)
|
||||
|
||||
# "早八" should be interpreted as 08:00
|
||||
# If hour is greater than 12, treat as 24-hour
|
||||
if hour_node.value > 12:
|
||||
is_24hour = True
|
||||
period = None
|
||||
else:
|
||||
raise ParserError(
|
||||
f"Expected number after '早', got {self.current_token.type} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
elif self.current_token.type == TokenType.LATE_NIGHT:
|
||||
self.eat(TokenType.LATE_NIGHT)
|
||||
is_24hour = True
|
||||
period = "PM"
|
||||
|
||||
# Expect a number next
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
|
||||
hour_token = self.current_token
|
||||
self.eat(hour_token.type)
|
||||
hour_node = NumberNode(value=hour_token.value)
|
||||
|
||||
# "晚十" should be interpreted as 22:00
|
||||
# Adjust hour to 24-hour format
|
||||
if hour_node.value <= 12:
|
||||
hour_node.value += 12
|
||||
is_24hour = True
|
||||
period = None
|
||||
else:
|
||||
raise ParserError(
|
||||
f"Expected number after '晚', got {self.current_token.type} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
else:
|
||||
# Regular time parsing
|
||||
hour_token = self.current_token
|
||||
self.eat(hour_token.type)
|
||||
|
||||
# Check for 点 or 时
|
||||
if self.current_token.type == TokenType.TIME_SEPARATOR:
|
||||
separator = self.current_token.value
|
||||
self.eat(TokenType.TIME_SEPARATOR)
|
||||
|
||||
if separator == '点':
|
||||
is_24hour = False
|
||||
elif separator == '时':
|
||||
is_24hour = True
|
||||
|
||||
hour_node = NumberNode(value=hour_token.value)
|
||||
|
||||
# Optional minutes
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
|
||||
minute_token = self.current_token
|
||||
self.eat(minute_token.type)
|
||||
|
||||
# Optional 分
|
||||
if self.current_token.type == TokenType.TIME_SEPARATOR and \
|
||||
self.current_token.value == '分':
|
||||
self.eat(TokenType.TIME_SEPARATOR)
|
||||
|
||||
minute_node = NumberNode(value=minute_token.value)
|
||||
|
||||
# Handle special markers
|
||||
if self.current_token.type == TokenType.HALF:
|
||||
self.eat(TokenType.HALF)
|
||||
minute_node = NumberNode(value=30)
|
||||
elif self.current_token.type == TokenType.QUARTER:
|
||||
self.eat(TokenType.QUARTER)
|
||||
minute_node = NumberNode(value=15)
|
||||
elif self.current_token.type == TokenType.ZHENG:
|
||||
self.eat(TokenType.ZHENG)
|
||||
if minute_node is None:
|
||||
minute_node = NumberNode(value=0)
|
||||
|
||||
# Optional 钟
|
||||
if self.current_token.type == TokenType.ZHONG:
|
||||
self.eat(TokenType.ZHONG)
|
||||
else:
|
||||
# If no separator, treat as hour-only time (like "三点")
|
||||
hour_node = NumberNode(value=hour_token.value)
|
||||
is_24hour = False
|
||||
|
||||
return TimeNode(
|
||||
hour=hour_node,
|
||||
minute=minute_node,
|
||||
second=second_node,
|
||||
is_24hour=is_24hour,
|
||||
period=period
|
||||
)
|
||||
|
||||
raise ParserError(
|
||||
f"Unable to parse time at position {self.current_token.position}"
|
||||
)
|
||||
|
||||
def parse_relative_date(self) -> RelativeDateNode:
|
||||
"""Parse a relative date specification."""
|
||||
years = 0
|
||||
months = 0
|
||||
weeks = 0
|
||||
days = 0
|
||||
|
||||
# Handle today variants
|
||||
if self.current_token.type == TokenType.RELATIVE_TODAY:
|
||||
self.eat(TokenType.RELATIVE_TODAY)
|
||||
days = 0
|
||||
elif self.current_token.type == TokenType.RELATIVE_TOMORROW:
|
||||
self.eat(TokenType.RELATIVE_TOMORROW)
|
||||
days = 1
|
||||
elif self.current_token.type == TokenType.RELATIVE_DAY_AFTER_TOMORROW:
|
||||
self.eat(TokenType.RELATIVE_DAY_AFTER_TOMORROW)
|
||||
days = 2
|
||||
elif self.current_token.type == TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW:
|
||||
self.eat(TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW)
|
||||
days = 3
|
||||
elif self.current_token.type == TokenType.RELATIVE_YESTERDAY:
|
||||
self.eat(TokenType.RELATIVE_YESTERDAY)
|
||||
days = -1
|
||||
elif self.current_token.type == TokenType.RELATIVE_DAY_BEFORE_YESTERDAY:
|
||||
self.eat(TokenType.RELATIVE_DAY_BEFORE_YESTERDAY)
|
||||
days = -2
|
||||
elif self.current_token.type == TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY:
|
||||
self.eat(TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY)
|
||||
days = -3
|
||||
else:
|
||||
# Check if this looks like an absolute date pattern before processing
|
||||
# Look ahead to see if this matches absolute date patterns
|
||||
is_likely_absolute_date = False
|
||||
|
||||
# Check for MM月DD[日号] patterns (like "6月20日")
|
||||
if (self.pos + 2 < len(self.tokens) and
|
||||
self.tokens[self.pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.tokens[self.pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
|
||||
self.tokens[self.pos + 1].value == '月' and
|
||||
self.tokens[self.pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
|
||||
is_likely_absolute_date = True
|
||||
|
||||
if is_likely_absolute_date:
|
||||
# This looks like an absolute date, skip relative date parsing
|
||||
raise ParserError("Looks like absolute date format")
|
||||
|
||||
# Try to parse extended relative time expressions
|
||||
# Handle patterns like "明年", "去年", "下个月", "上个月", etc.
|
||||
original_pos = self.pos
|
||||
try:
|
||||
# Check for "今年", "明年", "去年"
|
||||
if self.current_token.type == TokenType.RELATIVE_THIS and self.peek().type == TokenType.YEAR:
|
||||
self.eat(TokenType.RELATIVE_THIS)
|
||||
self.eat(TokenType.YEAR)
|
||||
years = 0 # Current year
|
||||
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.peek().type == TokenType.YEAR:
|
||||
self.eat(TokenType.RELATIVE_NEXT)
|
||||
self.eat(TokenType.YEAR)
|
||||
years = 1 # Next year
|
||||
elif self.current_token.type == TokenType.RELATIVE_LAST and self.peek().type == TokenType.YEAR:
|
||||
self.eat(TokenType.RELATIVE_LAST)
|
||||
self.eat(TokenType.YEAR)
|
||||
years = -1 # Last year
|
||||
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.current_token.value == "明年":
|
||||
self.eat(TokenType.RELATIVE_NEXT)
|
||||
years = 1 # Next year
|
||||
# Check if there's a month after "明年"
|
||||
if (self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.peek().type == TokenType.MONTH):
|
||||
# Parse the month
|
||||
month_node = self.parse_number()
|
||||
self.eat(TokenType.MONTH) # Eat the "月" token
|
||||
# Store the month in the months field as a special marker
|
||||
# We'll handle this in semantic analysis
|
||||
months = month_node.value - 100 # Use negative offset to indicate absolute month
|
||||
elif self.current_token.type == TokenType.RELATIVE_LAST and self.current_token.value == "去年":
|
||||
self.eat(TokenType.RELATIVE_LAST)
|
||||
years = -1 # Last year
|
||||
elif self.current_token.type == TokenType.RELATIVE_THIS and self.current_token.value == "今年":
|
||||
self.eat(TokenType.RELATIVE_THIS)
|
||||
years = 0 # Current year
|
||||
|
||||
# Check for "这个月", "下个月", "上个月"
|
||||
elif self.current_token.type == TokenType.RELATIVE_THIS and self.peek().type == TokenType.MONTH:
|
||||
self.eat(TokenType.RELATIVE_THIS)
|
||||
self.eat(TokenType.MONTH)
|
||||
months = 0 # Current month
|
||||
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.peek().type == TokenType.MONTH:
|
||||
self.eat(TokenType.RELATIVE_NEXT)
|
||||
self.eat(TokenType.MONTH)
|
||||
months = 1 # Next month
|
||||
|
||||
# Handle patterns like "下个月五号"
|
||||
if (self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.peek().type == TokenType.DAY):
|
||||
# Parse the day
|
||||
day_node = self.parse_number()
|
||||
self.eat(TokenType.DAY) # Eat the "号" token
|
||||
# Instead of adding days to the current date, we should set a specific day in the target month
|
||||
# We'll handle this in semantic analysis by setting a flag or special value
|
||||
days = 0 # Reset days - we'll handle the day differently
|
||||
# Use a special marker to indicate we want a specific day in the target month
|
||||
# For now, we'll just store the target day in the weeks field as a temporary solution
|
||||
weeks = day_node.value # This is a hack - we'll fix this in semantic analysis
|
||||
elif self.current_token.type == TokenType.RELATIVE_LAST and self.peek().type == TokenType.MONTH:
|
||||
self.eat(TokenType.RELATIVE_LAST)
|
||||
self.eat(TokenType.MONTH)
|
||||
months = -1 # Last month
|
||||
|
||||
# Check for "下周", "上周"
|
||||
elif self.current_token.type == TokenType.RELATIVE_NEXT and self.peek().type == TokenType.WEEK:
|
||||
self.eat(TokenType.RELATIVE_NEXT)
|
||||
self.eat(TokenType.WEEK)
|
||||
weeks = 1 # Next week
|
||||
elif self.current_token.type == TokenType.RELATIVE_LAST and self.peek().type == TokenType.WEEK:
|
||||
self.eat(TokenType.RELATIVE_LAST)
|
||||
self.eat(TokenType.WEEK)
|
||||
weeks = -1 # Last week
|
||||
|
||||
# Handle more complex patterns like "X年后", "X个月后", etc.
|
||||
elif self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
|
||||
# Check if this is likely an absolute date format (e.g., "2025年11月21日")
|
||||
# If the next token after the number is a date separator or date unit,
|
||||
# and the number looks like a year (4 digits) or the pattern continues,
|
||||
# it might be an absolute date. In that case, skip relative date parsing.
|
||||
|
||||
# Look ahead to see if this matches absolute date patterns
|
||||
lookahead_pos = self.pos
|
||||
is_likely_absolute_date = False
|
||||
|
||||
# Check for YYYY-MM-DD or YYYY/MM/DD patterns
|
||||
if (lookahead_pos + 4 < len(self.tokens) and
|
||||
self.tokens[lookahead_pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.tokens[lookahead_pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.YEAR] and
|
||||
self.tokens[lookahead_pos + 1].value in ['-', '/', '年'] and
|
||||
self.tokens[lookahead_pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.tokens[lookahead_pos + 3].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
|
||||
self.tokens[lookahead_pos + 3].value in ['-', '/', '月']):
|
||||
is_likely_absolute_date = True
|
||||
|
||||
# Check for YYYY年MM月DD patterns
|
||||
if (lookahead_pos + 4 < len(self.tokens) and
|
||||
self.tokens[lookahead_pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.tokens[lookahead_pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.YEAR] and
|
||||
self.tokens[lookahead_pos + 1].value == '年' and
|
||||
self.tokens[lookahead_pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.tokens[lookahead_pos + 3].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
|
||||
self.tokens[lookahead_pos + 3].value == '月'):
|
||||
is_likely_absolute_date = True
|
||||
|
||||
# Check for MM月DD[日号] patterns (like "6月20日")
|
||||
if (self.pos + 2 < len(self.tokens) and
|
||||
self.tokens[self.pos].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER] and
|
||||
self.tokens[self.pos + 1].type in [TokenType.DATE_SEPARATOR, TokenType.MONTH] and
|
||||
self.tokens[self.pos + 1].value == '月' and
|
||||
self.tokens[self.pos + 2].type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]):
|
||||
is_likely_absolute_date = True
|
||||
|
||||
if is_likely_absolute_date:
|
||||
# This looks like an absolute date, skip relative date parsing
|
||||
raise ParserError("Looks like absolute date format")
|
||||
|
||||
print(f"DEBUG: Parsing complex relative date pattern")
|
||||
# Parse the number
|
||||
number_node = self.parse_number()
|
||||
number_value = number_node.value
|
||||
print(f"DEBUG: Parsed number: {number_value}")
|
||||
|
||||
# Check the unit
|
||||
if self.current_token.type == TokenType.YEAR:
|
||||
self.eat(TokenType.YEAR)
|
||||
years = number_value
|
||||
print(f"DEBUG: Set years to {years}")
|
||||
elif self.current_token.type == TokenType.MONTH:
|
||||
self.eat(TokenType.MONTH)
|
||||
months = number_value
|
||||
print(f"DEBUG: Set months to {months}")
|
||||
elif self.current_token.type == TokenType.WEEK:
|
||||
self.eat(TokenType.WEEK)
|
||||
weeks = number_value
|
||||
print(f"DEBUG: Set weeks to {weeks}")
|
||||
elif self.current_token.type == TokenType.DAY:
|
||||
self.eat(TokenType.DAY)
|
||||
days = number_value
|
||||
print(f"DEBUG: Set days to {days}")
|
||||
else:
|
||||
print(f"DEBUG: Unexpected token type: {self.current_token.type}")
|
||||
raise ParserError(
|
||||
f"Expected time unit, got {self.current_token.type} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
|
||||
# Check direction (前/后)
|
||||
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
|
||||
print(f"DEBUG: Forward direction, values are already positive")
|
||||
# Values are already positive
|
||||
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
|
||||
print(f"DEBUG: Backward direction, negating values")
|
||||
years = -years
|
||||
months = -months
|
||||
weeks = -weeks
|
||||
days = -days
|
||||
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
raise ParserError(
|
||||
f"Expected relative date, got {self.current_token.type} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
|
||||
return RelativeDateNode(years=years, months=months, weeks=weeks, days=days)
|
||||
|
||||
def parse_weekday(self) -> WeekdayNode:
|
||||
"""Parse a weekday specification."""
|
||||
# Parse week scope (本, 上, 下)
|
||||
scope = "current"
|
||||
if self.current_token.type == TokenType.WEEK_SCOPE_CURRENT:
|
||||
self.eat(TokenType.WEEK_SCOPE_CURRENT)
|
||||
scope = "current"
|
||||
elif self.current_token.type == TokenType.WEEK_SCOPE_LAST:
|
||||
self.eat(TokenType.WEEK_SCOPE_LAST)
|
||||
scope = "last"
|
||||
elif self.current_token.type == TokenType.WEEK_SCOPE_NEXT:
|
||||
self.eat(TokenType.WEEK_SCOPE_NEXT)
|
||||
scope = "next"
|
||||
|
||||
# Parse weekday
|
||||
weekday_map = {
|
||||
TokenType.WEEKDAY_MONDAY: 0,
|
||||
TokenType.WEEKDAY_TUESDAY: 1,
|
||||
TokenType.WEEKDAY_WEDNESDAY: 2,
|
||||
TokenType.WEEKDAY_THURSDAY: 3,
|
||||
TokenType.WEEKDAY_FRIDAY: 4,
|
||||
TokenType.WEEKDAY_SATURDAY: 5,
|
||||
TokenType.WEEKDAY_SUNDAY: 6,
|
||||
# Handle Chinese numbers (1=Monday, 2=Tuesday, etc.)
|
||||
TokenType.CHINESE_NUMBER: lambda x: x - 1 if 1 <= x <= 7 else None,
|
||||
}
|
||||
|
||||
if self.current_token.type in weekday_map:
|
||||
if self.current_token.type == TokenType.CHINESE_NUMBER:
|
||||
# Handle numeric weekday (1=Monday, 2=Tuesday, etc.)
|
||||
weekday_num = self.current_token.value
|
||||
if 1 <= weekday_num <= 7:
|
||||
weekday = weekday_num - 1 # Convert to 0-based index
|
||||
self.eat(TokenType.CHINESE_NUMBER)
|
||||
return WeekdayNode(weekday=weekday, scope=scope)
|
||||
else:
|
||||
raise ParserError(
|
||||
f"Invalid weekday number: {weekday_num} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
else:
|
||||
weekday = weekday_map[self.current_token.type]
|
||||
self.eat(self.current_token.type)
|
||||
return WeekdayNode(weekday=weekday, scope=scope)
|
||||
|
||||
raise ParserError(
|
||||
f"Expected weekday, got {self.current_token.type} "
|
||||
f"at position {self.current_token.position}"
|
||||
)
|
||||
|
||||
def parse_relative_time(self) -> RelativeTimeNode:
|
||||
"""Parse a relative time specification."""
|
||||
hours = 0.0
|
||||
minutes = 0.0
|
||||
seconds = 0.0
|
||||
|
||||
def parse_relative_time(self) -> RelativeTimeNode:
|
||||
"""Parse a relative time specification."""
|
||||
hours = 0.0
|
||||
minutes = 0.0
|
||||
seconds = 0.0
|
||||
|
||||
# Parse sequences of relative time expressions
|
||||
while self.current_token.type in [
|
||||
TokenType.INTEGER, TokenType.CHINESE_NUMBER,
|
||||
TokenType.HALF, TokenType.QUARTER
|
||||
] or (self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD or
|
||||
self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD):
|
||||
|
||||
# Handle 半小时
|
||||
if (self.current_token.type == TokenType.HALF):
|
||||
self.eat(TokenType.HALF)
|
||||
# Optional 个
|
||||
if (self.current_token.type == TokenType.INTEGER and
|
||||
self.current_token.value == "个"):
|
||||
self.eat(TokenType.INTEGER)
|
||||
# Optional 小时
|
||||
if self.current_token.type == TokenType.HOUR:
|
||||
self.eat(TokenType.HOUR)
|
||||
hours += 0.5
|
||||
# Check for direction
|
||||
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
|
||||
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
|
||||
hours = -hours
|
||||
continue
|
||||
|
||||
# Handle 一刻钟 (15 minutes)
|
||||
if self.current_token.type == TokenType.QUARTER:
|
||||
self.eat(TokenType.QUARTER)
|
||||
# Optional 钟
|
||||
if self.current_token.type == TokenType.ZHONG:
|
||||
self.eat(TokenType.ZHONG)
|
||||
minutes += 15
|
||||
# Check for direction
|
||||
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
|
||||
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
|
||||
minutes = -minutes
|
||||
continue
|
||||
|
||||
# Parse number if we have one
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
|
||||
number_node = self.parse_number()
|
||||
number_value = number_node.value
|
||||
|
||||
# Determine unit and direction
|
||||
unit = None
|
||||
direction = 1 # Forward by default
|
||||
|
||||
# Check for unit
|
||||
if self.current_token.type == TokenType.HOUR:
|
||||
self.eat(TokenType.HOUR)
|
||||
# Optional 个
|
||||
if (self.current_token.type == TokenType.INTEGER and
|
||||
self.current_token.value == "个"):
|
||||
self.eat(TokenType.INTEGER)
|
||||
unit = "hour"
|
||||
elif self.current_token.type == TokenType.MINUTE:
|
||||
self.eat(TokenType.MINUTE)
|
||||
unit = "minute"
|
||||
elif self.current_token.type == TokenType.SECOND:
|
||||
self.eat(TokenType.SECOND)
|
||||
unit = "second"
|
||||
elif self.current_token.type == TokenType.TIME_SEPARATOR:
|
||||
# Handle "X点", "X分", "X秒" format
|
||||
sep_value = self.current_token.value
|
||||
self.eat(TokenType.TIME_SEPARATOR)
|
||||
if sep_value == "点":
|
||||
unit = "hour"
|
||||
# Optional 钟
|
||||
if self.current_token.type == TokenType.ZHONG:
|
||||
self.eat(TokenType.ZHONG)
|
||||
# If we have "X点" without a direction, this is likely an absolute time
|
||||
# Check if there's a direction after
|
||||
if not (self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD or
|
||||
self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD):
|
||||
# This is probably an absolute time, not relative time
|
||||
# Push back the number and break
|
||||
break
|
||||
elif sep_value == "分":
|
||||
unit = "minute"
|
||||
# Optional 钟
|
||||
if self.current_token.type == TokenType.ZHONG:
|
||||
self.eat(TokenType.ZHONG)
|
||||
elif sep_value == "秒":
|
||||
unit = "second"
|
||||
else:
|
||||
# If no unit specified, but we have a number followed by a direction,
|
||||
# assume it's hours
|
||||
if (self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD or
|
||||
self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD):
|
||||
unit = "hour"
|
||||
else:
|
||||
# If no unit and no direction, this might not be a relative time expression
|
||||
# Push the number back and break
|
||||
# We can't easily push back, so let's break
|
||||
break
|
||||
|
||||
# Check for direction (后/前)
|
||||
if self.current_token.type == TokenType.RELATIVE_DIRECTION_FORWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_FORWARD)
|
||||
direction = 1
|
||||
elif self.current_token.type == TokenType.RELATIVE_DIRECTION_BACKWARD:
|
||||
self.eat(TokenType.RELATIVE_DIRECTION_BACKWARD)
|
||||
direction = -1
|
||||
|
||||
# Apply the value based on unit
|
||||
if unit == "hour":
|
||||
hours += number_value * direction
|
||||
elif unit == "minute":
|
||||
minutes += number_value * direction
|
||||
elif unit == "second":
|
||||
seconds += number_value * direction
|
||||
continue
|
||||
|
||||
# If we still haven't handled the current token, break
|
||||
break
|
||||
|
||||
return RelativeTimeNode(hours=hours, minutes=minutes, seconds=seconds)
|
||||
|
||||
def parse_time_expression(self) -> TimeExpressionNode:
|
||||
"""Parse a complete time expression."""
|
||||
date_node = None
|
||||
time_node = None
|
||||
relative_date_node = None
|
||||
relative_time_node = None
|
||||
weekday_node = None
|
||||
|
||||
# Parse different parts of the expression
|
||||
while self.current_token.type != TokenType.EOF:
|
||||
# Try to parse date first (absolute dates should take precedence)
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER]:
|
||||
if date_node is None:
|
||||
original_pos = self.pos
|
||||
try:
|
||||
date_node = self.parse_date()
|
||||
continue
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
pass
|
||||
|
||||
# Try to parse relative date
|
||||
if self.current_token.type in [
|
||||
TokenType.RELATIVE_TODAY, TokenType.RELATIVE_TOMORROW,
|
||||
TokenType.RELATIVE_DAY_AFTER_TOMORROW, TokenType.RELATIVE_THREE_DAYS_AFTER_TOMORROW,
|
||||
TokenType.RELATIVE_YESTERDAY, TokenType.RELATIVE_DAY_BEFORE_YESTERDAY,
|
||||
TokenType.RELATIVE_THREE_DAYS_BEFORE_YESTERDAY,
|
||||
TokenType.INTEGER, TokenType.CHINESE_NUMBER, # For patterns like "X年后", "X个月后", etc.
|
||||
TokenType.RELATIVE_NEXT, TokenType.RELATIVE_LAST, TokenType.RELATIVE_THIS
|
||||
]:
|
||||
if relative_date_node is None:
|
||||
original_pos = self.pos
|
||||
try:
|
||||
relative_date_node = self.parse_relative_date()
|
||||
continue
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
pass
|
||||
|
||||
# Try to parse relative time first (since it can have numbers)
|
||||
if self.current_token.type in [
|
||||
TokenType.INTEGER, TokenType.CHINESE_NUMBER,
|
||||
TokenType.HALF, TokenType.QUARTER,
|
||||
TokenType.RELATIVE_DIRECTION_FORWARD, TokenType.RELATIVE_DIRECTION_BACKWARD
|
||||
]:
|
||||
if relative_time_node is None:
|
||||
original_pos = self.pos
|
||||
try:
|
||||
relative_time_node = self.parse_relative_time()
|
||||
# Only continue if we actually parsed some relative time
|
||||
if relative_time_node.hours != 0 or relative_time_node.minutes != 0 or relative_time_node.seconds != 0:
|
||||
continue
|
||||
else:
|
||||
# If we didn't parse any relative time, reset position
|
||||
self.pos = original_pos
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
pass
|
||||
|
||||
# Try to parse time
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER, TokenType.TIME_SEPARATOR, TokenType.PERIOD_AM, TokenType.PERIOD_PM]:
|
||||
if time_node is None:
|
||||
original_pos = self.pos
|
||||
try:
|
||||
time_node = self.parse_time()
|
||||
continue
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
pass
|
||||
|
||||
# Try to parse time
|
||||
if self.current_token.type in [TokenType.INTEGER, TokenType.CHINESE_NUMBER, TokenType.TIME_SEPARATOR, TokenType.PERIOD_AM, TokenType.PERIOD_PM]:
|
||||
if time_node is None:
|
||||
original_pos = self.pos
|
||||
try:
|
||||
time_node = self.parse_time()
|
||||
continue
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
pass
|
||||
|
||||
# Try to parse weekday
|
||||
if self.current_token.type in [
|
||||
TokenType.WEEK_SCOPE_CURRENT, TokenType.WEEK_SCOPE_LAST, TokenType.WEEK_SCOPE_NEXT,
|
||||
TokenType.WEEKDAY_MONDAY, TokenType.WEEKDAY_TUESDAY, TokenType.WEEKDAY_WEDNESDAY,
|
||||
TokenType.WEEKDAY_THURSDAY, TokenType.WEEKDAY_FRIDAY, TokenType.WEEKDAY_SATURDAY,
|
||||
TokenType.WEEKDAY_SUNDAY
|
||||
]:
|
||||
if weekday_node is None:
|
||||
original_pos = self.pos
|
||||
try:
|
||||
weekday_node = self.parse_weekday()
|
||||
continue
|
||||
except ParserError:
|
||||
# Reset position if parsing failed
|
||||
self.pos = original_pos
|
||||
pass
|
||||
|
||||
# If we get here and couldn't parse anything, skip the token
|
||||
self.pos += 1
|
||||
|
||||
return TimeExpressionNode(
|
||||
date=date_node,
|
||||
time=time_node,
|
||||
relative_date=relative_date_node,
|
||||
relative_time=relative_time_node,
|
||||
weekday=weekday_node
|
||||
)
|
||||
|
||||
def parse(self) -> TimeExpressionNode:
|
||||
"""Parse the complete time expression and return the AST."""
|
||||
return self.parse_time_expression()
|
||||
71
konabot/common/ptimeparse/ptime_ast.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""
|
||||
Abstract Syntax Tree (AST) nodes for the time expression parser.
|
||||
"""
|
||||
|
||||
from abc import ABC
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASTNode(ABC):
|
||||
"""Base class for all AST nodes."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberNode(ASTNode):
|
||||
"""Represents a numeric value."""
|
||||
value: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateNode(ASTNode):
|
||||
"""Represents a date specification."""
|
||||
year: Optional[ASTNode]
|
||||
month: Optional[ASTNode]
|
||||
day: Optional[ASTNode]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeNode(ASTNode):
|
||||
"""Represents a time specification."""
|
||||
hour: Optional[ASTNode]
|
||||
minute: Optional[ASTNode]
|
||||
second: Optional[ASTNode]
|
||||
is_24hour: bool = False
|
||||
period: Optional[str] = None # AM or PM
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelativeDateNode(ASTNode):
|
||||
"""Represents a relative date specification."""
|
||||
years: int = 0
|
||||
months: int = 0
|
||||
weeks: int = 0
|
||||
days: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelativeTimeNode(ASTNode):
|
||||
"""Represents a relative time specification."""
|
||||
hours: float = 0.0
|
||||
minutes: float = 0.0
|
||||
seconds: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeekdayNode(ASTNode):
|
||||
"""Represents a weekday specification."""
|
||||
weekday: int # 0=Monday, 6=Sunday
|
||||
scope: str # current, last, next
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeExpressionNode(ASTNode):
|
||||
"""Represents a complete time expression."""
|
||||
date: Optional[DateNode] = None
|
||||
time: Optional[TimeNode] = None
|
||||
relative_date: Optional[RelativeDateNode] = None
|
||||
relative_time: Optional[RelativeTimeNode] = None
|
||||
weekday: Optional[WeekdayNode] = None
|
||||
95
konabot/common/ptimeparse/ptime_token.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""
|
||||
Token definitions for the time parser.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class TokenType(Enum):
|
||||
"""Types of tokens recognized by the lexer."""
|
||||
|
||||
# Numbers
|
||||
INTEGER = "INTEGER"
|
||||
CHINESE_NUMBER = "CHINESE_NUMBER"
|
||||
|
||||
# Time units
|
||||
YEAR = "YEAR"
|
||||
MONTH = "MONTH"
|
||||
DAY = "DAY"
|
||||
WEEK = "WEEK"
|
||||
HOUR = "HOUR"
|
||||
MINUTE = "MINUTE"
|
||||
SECOND = "SECOND"
|
||||
|
||||
# Date separators
|
||||
DATE_SEPARATOR = "DATE_SEPARATOR" # -, /, 年, 月, 日, 号
|
||||
|
||||
# Time separators
|
||||
TIME_SEPARATOR = "TIME_SEPARATOR" # :, 点, 时, 分, 秒
|
||||
|
||||
# Period indicators
|
||||
PERIOD_AM = "PERIOD_AM" # 上午, 早上, 早晨, etc.
|
||||
PERIOD_PM = "PERIOD_PM" # 下午, 晚上, 中午, etc.
|
||||
|
||||
# Relative time
|
||||
RELATIVE_TODAY = "RELATIVE_TODAY" # 今天, 今晚, 今早, etc.
|
||||
RELATIVE_TOMORROW = "RELATIVE_TOMORROW" # 明天
|
||||
RELATIVE_DAY_AFTER_TOMORROW = "RELATIVE_DAY_AFTER_TOMORROW" # 后天
|
||||
RELATIVE_THREE_DAYS_AFTER_TOMORROW = "RELATIVE_THREE_DAYS_AFTER_TOMORROW" # 大后天
|
||||
RELATIVE_YESTERDAY = "RELATIVE_YESTERDAY" # 昨天
|
||||
RELATIVE_DAY_BEFORE_YESTERDAY = "RELATIVE_DAY_BEFORE_YESTERDAY" # 前天
|
||||
RELATIVE_THREE_DAYS_BEFORE_YESTERDAY = "RELATIVE_THREE_DAYS_BEFORE_YESTERDAY" # 大前天
|
||||
RELATIVE_DIRECTION_FORWARD = "RELATIVE_DIRECTION_FORWARD" # 后, 以后, 之后
|
||||
RELATIVE_DIRECTION_BACKWARD = "RELATIVE_DIRECTION_BACKWARD" # 前, 以前, 之前
|
||||
|
||||
# Extended relative time
|
||||
RELATIVE_NEXT = "RELATIVE_NEXT" # 下
|
||||
RELATIVE_LAST = "RELATIVE_LAST" # 上, 去
|
||||
RELATIVE_THIS = "RELATIVE_THIS" # 这, 本
|
||||
|
||||
# Week days
|
||||
WEEKDAY_MONDAY = "WEEKDAY_MONDAY"
|
||||
WEEKDAY_TUESDAY = "WEEKDAY_TUESDAY"
|
||||
WEEKDAY_WEDNESDAY = "WEEKDAY_WEDNESDAY"
|
||||
WEEKDAY_THURSDAY = "WEEKDAY_THURSDAY"
|
||||
WEEKDAY_FRIDAY = "WEEKDAY_FRIDAY"
|
||||
WEEKDAY_SATURDAY = "WEEKDAY_SATURDAY"
|
||||
WEEKDAY_SUNDAY = "WEEKDAY_SUNDAY"
|
||||
|
||||
# Week scope
|
||||
WEEK_SCOPE_CURRENT = "WEEK_SCOPE_CURRENT" # 本
|
||||
WEEK_SCOPE_LAST = "WEEK_SCOPE_LAST" # 上
|
||||
WEEK_SCOPE_NEXT = "WEEK_SCOPE_NEXT" # 下
|
||||
|
||||
# Special time markers
|
||||
HALF = "HALF" # 半
|
||||
QUARTER = "QUARTER" # 一刻
|
||||
ZHENG = "ZHENG" # 整
|
||||
ZHONG = "ZHONG" # 钟
|
||||
|
||||
# Student-friendly time expressions
|
||||
EARLY_MORNING = "EARLY_MORNING" # 早X
|
||||
LATE_NIGHT = "LATE_NIGHT" # 晚X
|
||||
|
||||
# Whitespace
|
||||
WHITESPACE = "WHITESPACE"
|
||||
|
||||
# End of input
|
||||
EOF = "EOF"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Token:
|
||||
"""Represents a single token from the lexer."""
|
||||
|
||||
type: TokenType
|
||||
value: Union[str, int]
|
||||
position: int
|
||||
|
||||
def __str__(self):
|
||||
return f"Token({self.type.value}, {repr(self.value)}, {self.position})"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
369
konabot/common/ptimeparse/semantic.py
Normal file
@ -0,0 +1,369 @@
|
||||
"""
|
||||
Semantic analyzer for time expressions that evaluates the AST and produces datetime objects.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import calendar
|
||||
from typing import Optional
|
||||
|
||||
from .ptime_ast import (
|
||||
TimeExpressionNode, DateNode, TimeNode,
|
||||
RelativeDateNode, RelativeTimeNode, WeekdayNode, NumberNode
|
||||
)
|
||||
from .err import TokenUnhandledException
|
||||
|
||||
|
||||
class SemanticAnalyzer:
|
||||
"""Semantic analyzer that evaluates time expression ASTs."""
|
||||
|
||||
def __init__(self, now: Optional[datetime.datetime] = None):
|
||||
self.now = now or datetime.datetime.now()
|
||||
|
||||
def evaluate_number(self, node: NumberNode) -> int:
|
||||
"""Evaluate a number node."""
|
||||
return node.value
|
||||
|
||||
def evaluate_date(self, node: DateNode) -> datetime.date:
|
||||
"""Evaluate a date node."""
|
||||
year = self.now.year
|
||||
month = 1
|
||||
day = 1
|
||||
|
||||
if node.year is not None:
|
||||
year = self.evaluate_number(node.year)
|
||||
if node.month is not None:
|
||||
month = self.evaluate_number(node.month)
|
||||
if node.day is not None:
|
||||
day = self.evaluate_number(node.day)
|
||||
|
||||
return datetime.date(year, month, day)
|
||||
|
||||
def evaluate_time(self, node: TimeNode) -> datetime.time:
|
||||
"""Evaluate a time node."""
|
||||
hour = 0
|
||||
minute = 0
|
||||
second = 0
|
||||
|
||||
if node.hour is not None:
|
||||
hour = self.evaluate_number(node.hour)
|
||||
if node.minute is not None:
|
||||
minute = self.evaluate_number(node.minute)
|
||||
if node.second is not None:
|
||||
second = self.evaluate_number(node.second)
|
||||
|
||||
# Handle 24-hour vs 12-hour format
|
||||
if not node.is_24hour and node.period is not None:
|
||||
if node.period == "AM":
|
||||
if hour == 12:
|
||||
hour = 0
|
||||
elif node.period == "PM":
|
||||
if hour != 12 and hour <= 12:
|
||||
hour += 12
|
||||
|
||||
# Validate time values
|
||||
if not (0 <= hour <= 23):
|
||||
raise TokenUnhandledException(f"Invalid hour: {hour}")
|
||||
if not (0 <= minute <= 59):
|
||||
raise TokenUnhandledException(f"Invalid minute: {minute}")
|
||||
if not (0 <= second <= 59):
|
||||
raise TokenUnhandledException(f"Invalid second: {second}")
|
||||
|
||||
return datetime.time(hour, minute, second)
|
||||
|
||||
def evaluate_relative_date(self, node: RelativeDateNode) -> datetime.timedelta:
|
||||
"""Evaluate a relative date node."""
|
||||
# Start with current time
|
||||
result = self.now
|
||||
|
||||
# Special case: If weeks contains a target day (hacky way to pass target day info)
|
||||
# This is for patterns like "下个月五号"
|
||||
if node.weeks > 0 and node.weeks <= 31: # Valid day range
|
||||
target_day = node.weeks
|
||||
|
||||
# Calculate the target month
|
||||
if node.months != 0:
|
||||
# Handle month arithmetic carefully
|
||||
total_months = result.month + node.months - 1
|
||||
new_year = result.year + total_months // 12
|
||||
new_month = total_months % 12 + 1
|
||||
|
||||
# Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
|
||||
max_day_in_target_month = calendar.monthrange(new_year, new_month)[1]
|
||||
target_day = min(target_day, max_day_in_target_month)
|
||||
|
||||
try:
|
||||
result = result.replace(year=new_year, month=new_month, day=target_day)
|
||||
except ValueError:
|
||||
# Handle edge cases
|
||||
result = result.replace(year=new_year, month=new_month, day=max_day_in_target_month)
|
||||
|
||||
# Return the difference between the new date and the original date
|
||||
return result - self.now
|
||||
|
||||
# Apply years
|
||||
if node.years != 0:
|
||||
# Handle year arithmetic carefully due to leap years
|
||||
new_year = result.year + node.years
|
||||
try:
|
||||
result = result.replace(year=new_year)
|
||||
except ValueError:
|
||||
# Handle leap year edge case (Feb 29 -> Feb 28)
|
||||
result = result.replace(year=new_year, month=2, day=28)
|
||||
|
||||
# Apply months
|
||||
if node.months != 0:
|
||||
# Check if this is a special marker for absolute month (negative offset)
|
||||
if node.months < 0:
|
||||
# This is an absolute month specification (e.g., from "明年五月")
|
||||
absolute_month = node.months + 100
|
||||
if 1 <= absolute_month <= 12:
|
||||
result = result.replace(year=result.year, month=absolute_month, day=result.day)
|
||||
else:
|
||||
# Handle month arithmetic carefully
|
||||
total_months = result.month + node.months - 1
|
||||
new_year = result.year + total_months // 12
|
||||
new_month = total_months % 12 + 1
|
||||
|
||||
# Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
|
||||
new_day = min(result.day, calendar.monthrange(new_year, new_month)[1])
|
||||
|
||||
result = result.replace(year=new_year, month=new_month, day=new_day)
|
||||
|
||||
# Apply weeks and days
|
||||
if node.weeks != 0 or node.days != 0:
|
||||
delta_days = node.weeks * 7 + node.days
|
||||
result = result + datetime.timedelta(days=delta_days)
|
||||
|
||||
return result - self.now
|
||||
|
||||
def evaluate_relative_time(self, node: RelativeTimeNode) -> datetime.timedelta:
|
||||
"""Evaluate a relative time node."""
|
||||
# Convert all values to seconds for precise calculation
|
||||
total_seconds = (
|
||||
node.hours * 3600 +
|
||||
node.minutes * 60 +
|
||||
node.seconds
|
||||
)
|
||||
|
||||
return datetime.timedelta(seconds=total_seconds)
|
||||
|
||||
def evaluate_weekday(self, node: WeekdayNode) -> datetime.timedelta:
|
||||
"""Evaluate a weekday node."""
|
||||
current_weekday = self.now.weekday() # 0=Monday, 6=Sunday
|
||||
target_weekday = node.weekday
|
||||
|
||||
if node.scope == "current":
|
||||
delta = target_weekday - current_weekday
|
||||
elif node.scope == "last":
|
||||
delta = target_weekday - current_weekday - 7
|
||||
elif node.scope == "next":
|
||||
delta = target_weekday - current_weekday + 7
|
||||
else:
|
||||
delta = target_weekday - current_weekday
|
||||
|
||||
return datetime.timedelta(days=delta)
|
||||
|
||||
def infer_smart_time(self, hour: int, minute: int = 0, second: int = 0, base_time: Optional[datetime.datetime] = None) -> datetime.datetime:
|
||||
"""
|
||||
Smart time inference based on current time.
|
||||
|
||||
For example:
|
||||
- If now is 14:30 and user says "3点", interpret as 15:00
|
||||
- If now is 14:30 and user says "1点", interpret as next day 01:00
|
||||
- If now is 8:00 and user says "3点", interpret as 15:00
|
||||
- If now is 8:00 and user says "9点", interpret as 09:00
|
||||
"""
|
||||
# Use base_time if provided, otherwise use self.now
|
||||
now = base_time if base_time is not None else self.now
|
||||
|
||||
# Handle 24-hour format directly (13-23)
|
||||
if 13 <= hour <= 23:
|
||||
candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += datetime.timedelta(days=1)
|
||||
return candidate
|
||||
|
||||
# Handle 12 (noon/midnight)
|
||||
if hour == 12:
|
||||
# For 12 specifically, we need to be more careful
|
||||
# Try noon first
|
||||
noon_candidate = now.replace(hour=12, minute=minute, second=second, microsecond=0)
|
||||
midnight_candidate = now.replace(hour=0, minute=minute, second=second, microsecond=0)
|
||||
|
||||
# Special case: If it's afternoon or evening, "十二点" likely means next day midnight
|
||||
if now.hour >= 12:
|
||||
result = midnight_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
|
||||
# If noon is in the future and closer than midnight, use it
|
||||
if noon_candidate > now and (midnight_candidate <= now or noon_candidate < midnight_candidate):
|
||||
return noon_candidate
|
||||
# If midnight is in the future, use it
|
||||
elif midnight_candidate > now:
|
||||
return midnight_candidate
|
||||
# Both are in the past, use the closer one
|
||||
elif noon_candidate > midnight_candidate:
|
||||
return noon_candidate
|
||||
# Otherwise use midnight next day
|
||||
else:
|
||||
result = midnight_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
|
||||
# Handle 1-11 (12-hour format)
|
||||
if 1 <= hour <= 11:
|
||||
# Calculate 12-hour format candidates
|
||||
pm_hour = hour + 12
|
||||
pm_candidate = now.replace(hour=pm_hour, minute=minute, second=second, microsecond=0)
|
||||
am_candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
||||
|
||||
# Special case: If it's afternoon (12:00-18:00) and the hour is 1-6,
|
||||
# user might mean either PM today or AM tomorrow.
|
||||
# But if PM is in the future, that's more likely what they mean.
|
||||
if 12 <= now.hour <= 18 and 1 <= hour <= 6:
|
||||
if pm_candidate > now:
|
||||
return pm_candidate
|
||||
else:
|
||||
# PM is in the past, so use AM tomorrow
|
||||
result = am_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
|
||||
# Special case: If it's late evening (after 22:00) and user specifies early morning hours (1-5),
|
||||
# user likely means next day early morning
|
||||
if now.hour >= 22 and 1 <= hour <= 5:
|
||||
result = am_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
|
||||
# Special case: In the morning (0-12:00)
|
||||
if now.hour < 12:
|
||||
# In the morning, for hours 1-11, generally prefer AM interpretation
|
||||
# unless it's a very early hour that's much earlier than current time
|
||||
# Only push to next day for very early hours (1-2) that are significantly earlier
|
||||
if hour <= 2 and hour < now.hour and now.hour - hour >= 6:
|
||||
# Very early morning hour that's significantly earlier, use next day
|
||||
result = am_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
else:
|
||||
# For morning, generally prefer AM if it's in the future
|
||||
if am_candidate > now:
|
||||
return am_candidate
|
||||
# If PM is in the future, use it
|
||||
elif pm_candidate > now:
|
||||
return pm_candidate
|
||||
# Both are in the past, prefer AM if it's closer
|
||||
elif am_candidate > pm_candidate:
|
||||
return am_candidate
|
||||
# Otherwise use PM next day
|
||||
else:
|
||||
result = pm_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
else:
|
||||
# General case: choose the one that's in the future and closer
|
||||
if pm_candidate > now and (am_candidate <= now or pm_candidate < am_candidate):
|
||||
return pm_candidate
|
||||
elif am_candidate > now:
|
||||
return am_candidate
|
||||
# Both are in the past, use the closer one
|
||||
elif pm_candidate > am_candidate:
|
||||
return pm_candidate
|
||||
# Otherwise use AM next day
|
||||
else:
|
||||
result = am_candidate + datetime.timedelta(days=1)
|
||||
return result
|
||||
|
||||
# Handle 0 (midnight)
|
||||
if hour == 0:
|
||||
candidate = now.replace(hour=0, minute=minute, second=second, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += datetime.timedelta(days=1)
|
||||
return candidate
|
||||
|
||||
# Default case (should not happen with valid input)
|
||||
candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += datetime.timedelta(days=1)
|
||||
return candidate
|
||||
|
||||
def evaluate(self, node: TimeExpressionNode) -> datetime.datetime:
|
||||
"""Evaluate a complete time expression node."""
|
||||
result = self.now
|
||||
|
||||
# Apply relative date (should set time to 00:00:00 for dates)
|
||||
if node.relative_date is not None:
|
||||
delta = self.evaluate_relative_date(node.relative_date)
|
||||
result = result + delta
|
||||
# For relative dates like "今天", "明天", set time to 00:00:00
|
||||
# But only for cases where we're dealing with days, not years/months
|
||||
if (node.date is None and node.time is None and node.weekday is None and
|
||||
node.relative_date.years == 0 and node.relative_date.months == 0):
|
||||
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Apply weekday
|
||||
if node.weekday is not None:
|
||||
delta = self.evaluate_weekday(node.weekday)
|
||||
result = result + delta
|
||||
# For weekdays, set time to 00:00:00
|
||||
if node.date is None and node.time is None:
|
||||
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Apply relative time
|
||||
if node.relative_time is not None:
|
||||
delta = self.evaluate_relative_time(node.relative_time)
|
||||
result = result + delta
|
||||
|
||||
# Apply absolute date
|
||||
if node.date is not None:
|
||||
date = self.evaluate_date(node.date)
|
||||
result = result.replace(year=date.year, month=date.month, day=date.day)
|
||||
# For absolute dates without time, set time to 00:00:00
|
||||
if node.time is None:
|
||||
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Apply time
|
||||
if node.time is not None:
|
||||
time = self.evaluate_time(node.time)
|
||||
|
||||
# Handle explicit period or student-friendly expressions
|
||||
if node.time.is_24hour or node.time.period is not None:
|
||||
# Handle explicit period
|
||||
if not node.time.is_24hour and node.time.period is not None:
|
||||
hour = time.hour
|
||||
minute = time.minute
|
||||
second = time.second
|
||||
|
||||
if node.time.period == "AM":
|
||||
if hour == 12:
|
||||
hour = 0
|
||||
elif node.time.period == "PM":
|
||||
# Special case: "晚上十二点" should be interpreted as next day 00:00
|
||||
if hour == 12 and minute == 0 and second == 0:
|
||||
# Move to next day at 00:00:00
|
||||
result = result.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
# Skip the general replacement since we've already handled it
|
||||
skip_general_replacement = True
|
||||
else:
|
||||
# For other PM times, convert to 24-hour format
|
||||
if hour != 12 and hour <= 12:
|
||||
hour += 12
|
||||
|
||||
# Validate hour
|
||||
if not (0 <= hour <= 23):
|
||||
raise TokenUnhandledException(f"Invalid hour: {hour}")
|
||||
|
||||
# Only do general replacement if we haven't handled it specially
|
||||
if not locals().get('skip_general_replacement', False):
|
||||
result = result.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
||||
else:
|
||||
# Already in 24-hour format
|
||||
result = result.replace(hour=time.hour, minute=time.minute, second=time.second, microsecond=0)
|
||||
else:
|
||||
# Use smart time inference for regular times
|
||||
# But if we have an explicit date, treat the time as 24-hour format
|
||||
if node.date is not None or node.relative_date is not None:
|
||||
# For explicit dates, treat time as 24-hour format
|
||||
result = result.replace(hour=time.hour, minute=time.minute or 0, second=time.second or 0, microsecond=0)
|
||||
else:
|
||||
# Use smart time inference for regular times
|
||||
smart_time = self.infer_smart_time(time.hour, time.minute, time.second, base_time=result)
|
||||
result = smart_time
|
||||
|
||||
return result
|
||||
54
konabot/common/username.py
Normal file
@ -0,0 +1,54 @@
|
||||
import re
|
||||
import nonebot
|
||||
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBBot
|
||||
|
||||
|
||||
class UsernameManager:
|
||||
grouped_data: dict[int, dict[int, str]]
|
||||
individual_data: dict[int, str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.grouped_data = {}
|
||||
self.individual_data = {}
|
||||
|
||||
async def update(self):
|
||||
for bot in nonebot.get_bots().values():
|
||||
if isinstance(bot, OBBot):
|
||||
for user in await bot.get_friend_list():
|
||||
uid = user["user_id"]
|
||||
nickname = user["nickname"]
|
||||
self.individual_data[uid] = nickname
|
||||
for group in await bot.get_group_list():
|
||||
gid = group["group_id"]
|
||||
for member in await bot.get_group_member_list(group_id=gid):
|
||||
uid = member["user_id"]
|
||||
card = member.get("card", "")
|
||||
nickname = member.get("nickname", "")
|
||||
if card:
|
||||
self.grouped_data.setdefault(gid, {})[uid] = card
|
||||
if nickname:
|
||||
self.individual_data[uid] = nickname
|
||||
|
||||
def get(self, qqid: int, groupid: int | None = None) -> str:
|
||||
if groupid is not None and groupid in self.grouped_data:
|
||||
n = self.grouped_data[groupid].get(qqid)
|
||||
if n is not None:
|
||||
return n
|
||||
if qqid in self.individual_data:
|
||||
return self.individual_data[qqid]
|
||||
return str(qqid)
|
||||
|
||||
|
||||
manager = UsernameManager()
|
||||
|
||||
def get_username(qqid: int | str, group: int | str | None = None):
|
||||
if isinstance(group, str):
|
||||
group = None if not re.match(r"^\d+$", group) else int(group)
|
||||
if isinstance(qqid, str):
|
||||
if re.match(r"^\d+$", qqid):
|
||||
qqid = int(qqid)
|
||||
else:
|
||||
return qqid
|
||||
return manager.get(qqid, group)
|
||||
|
||||
17
konabot/common/utils/to_async.py
Normal file
@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from typing import Awaitable, Callable, ParamSpec, TypeVar
|
||||
|
||||
|
||||
TA = ParamSpec("TA")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def make_async(func: Callable[TA, T]) -> Callable[TA, Awaitable[T]]:
|
||||
@functools.wraps(func, assigned=("__module__", "__name__", "__qualname__", "__doc__", "__annotations__"))
|
||||
async def wrapper(*args: TA.args, **kwargs: TA.kwargs):
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
9
konabot/common/web_render/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from .config import web_render_config
|
||||
from .core import WebRenderer as WebRenderer
|
||||
from .core import WebRendererInstance as WebRendererInstance
|
||||
|
||||
|
||||
def konaweb(sub_url: str):
|
||||
sub_url = '/' + sub_url.removeprefix('/')
|
||||
return web_render_config.module_web_render_weburl.removesuffix('/') + sub_url
|
||||
|
||||
20
konabot/common/web_render/config.py
Normal file
@ -0,0 +1,20 @@
|
||||
import nonebot
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Config(BaseModel):
|
||||
module_web_render_weburl: str = "localhost:5173"
|
||||
module_web_render_instance: str = ""
|
||||
module_web_render_playwright_ws: str = ""
|
||||
|
||||
def get_instance_baseurl(self):
|
||||
if self.module_web_render_instance:
|
||||
return self.module_web_render_instance.removesuffix('/')
|
||||
config = nonebot.get_driver().config
|
||||
ip = str(config.host)
|
||||
if ip == "0.0.0.0":
|
||||
ip = "127.0.0.1"
|
||||
port = config.port
|
||||
return f'http://{ip}:{port}'
|
||||
|
||||
web_render_config = nonebot.get_plugin_config(Config)
|
||||
403
konabot/common/web_render/core.py
Normal file
@ -0,0 +1,403 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import queue
|
||||
from typing import Any, Callable, Coroutine, Generic, TypeVar
|
||||
from loguru import logger
|
||||
from playwright.async_api import (
|
||||
Page,
|
||||
Playwright,
|
||||
async_playwright,
|
||||
Browser,
|
||||
BrowserContext,
|
||||
Error as PlaywrightError,
|
||||
)
|
||||
|
||||
from .config import web_render_config
|
||||
from playwright.async_api import ConsoleMessage, Page
|
||||
|
||||
T = TypeVar("T")
|
||||
TFunction = Callable[[T], Coroutine[Any, Any, Any]]
|
||||
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
||||
|
||||
|
||||
class WebRenderer:
|
||||
browser_pool: queue.Queue["WebRendererInstance"] = queue.Queue()
|
||||
context_pool: dict[int, BrowserContext] = {} # 长期挂载的浏览器上下文池
|
||||
page_pool: dict[str, Page] = {} # 长期挂载的页面池
|
||||
|
||||
@classmethod
|
||||
async def get_browser_instance(cls) -> "WebRendererInstance":
|
||||
if cls.browser_pool.empty():
|
||||
if web_render_config.module_web_render_playwright_ws:
|
||||
instance = await RemotePlaywrightInstance.create(
|
||||
web_render_config.module_web_render_playwright_ws
|
||||
)
|
||||
else:
|
||||
instance = await LocalPlaywrightInstance.create()
|
||||
cls.browser_pool.put(instance)
|
||||
instance = cls.browser_pool.get()
|
||||
cls.browser_pool.put(instance)
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
async def render(
|
||||
cls,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
访问指定URL并返回截图
|
||||
|
||||
:param url: 目标URL
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(
|
||||
f"Using WebRendererInstance {id(instance)} to render {url} targeting {target}"
|
||||
)
|
||||
return await instance.render(
|
||||
url, target, params=params, other_function=other_function, timeout=timeout
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def render_file(
|
||||
cls,
|
||||
file_path: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
访问指定本地文件URL并返回截图
|
||||
|
||||
:param file_path: 目标文件路径
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(
|
||||
f"Using WebRendererInstance {id(instance)} to render file {file_path} targeting {target}"
|
||||
)
|
||||
return await instance.render_file(
|
||||
file_path,
|
||||
target,
|
||||
params=params,
|
||||
other_function=other_function,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def render_with_persistent_page(
|
||||
cls,
|
||||
page_id: str,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
使用长期挂载的页面进行渲染
|
||||
|
||||
:param page_id: 页面唯一标识符
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(
|
||||
f"Using WebRendererInstance {id(instance)} to render with persistent page {page_id} targeting {target}"
|
||||
)
|
||||
return await instance.render_with_persistent_page(
|
||||
page_id,
|
||||
url,
|
||||
target,
|
||||
params=params,
|
||||
other_function=other_function,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_persistent_page(cls, page_id: str, url: str) -> Page:
|
||||
"""
|
||||
获取长期挂载的页面,如果不存在则创建一个新的页面并存储
|
||||
"""
|
||||
if page_id in cls.page_pool:
|
||||
return cls.page_pool[page_id]
|
||||
|
||||
async def on_console(msg: ConsoleMessage):
|
||||
logger.debug(f"WEB CONSOLE {msg.text}")
|
||||
|
||||
instance = await cls.get_browser_instance()
|
||||
if isinstance(instance, RemotePlaywrightInstance):
|
||||
context = await instance.browser.new_context()
|
||||
page = await context.new_page()
|
||||
await page.goto(url)
|
||||
cls.page_pool[page_id] = page
|
||||
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||
|
||||
page.on('console', on_console)
|
||||
|
||||
return page
|
||||
elif isinstance(instance, LocalPlaywrightInstance):
|
||||
context = await instance.browser.new_context()
|
||||
page = await context.new_page()
|
||||
await page.goto(url)
|
||||
cls.page_pool[page_id] = page
|
||||
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||
|
||||
page.on('console', on_console)
|
||||
|
||||
return page
|
||||
else:
|
||||
raise NotImplementedError("Unsupported WebRendererInstance type")
|
||||
|
||||
@classmethod
|
||||
async def close_persistent_page(cls, page_id: str) -> None:
|
||||
"""
|
||||
关闭并移除长期挂载的页面
|
||||
|
||||
:param page_id: 页面唯一标识符
|
||||
"""
|
||||
if page_id in cls.page_pool:
|
||||
page = cls.page_pool[page_id]
|
||||
await page.close()
|
||||
del cls.page_pool[page_id]
|
||||
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
|
||||
|
||||
|
||||
class WebRendererInstance(ABC, Generic[T]):
|
||||
@abstractmethod
|
||||
async def render(
|
||||
self,
|
||||
url: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: TFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes: ...
|
||||
|
||||
@abstractmethod
|
||||
async def render_file(
|
||||
self,
|
||||
file_path: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes: ...
|
||||
|
||||
@abstractmethod
|
||||
async def render_with_persistent_page(
|
||||
self,
|
||||
page_id: str,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
class PlaywrightInstance(WebRendererInstance[Page]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def browser(self) -> Browser: ...
|
||||
|
||||
async def render(
|
||||
self,
|
||||
url: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
"""
|
||||
访问指定URL并返回截图
|
||||
|
||||
:param url: 目标URL
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param index: 如果目标是一个列表,指定要截图的元素索引
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
"""
|
||||
async with self.lock:
|
||||
context = await self.browser.new_context()
|
||||
page = await context.new_page()
|
||||
screenshot = await self.inner_render(
|
||||
page, url, target, index, params or {}, other_function, timeout
|
||||
)
|
||||
await page.close()
|
||||
await context.close()
|
||||
return screenshot
|
||||
|
||||
async def render_file(
|
||||
self,
|
||||
file_path: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict[str, Any] | None = None,
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
file_path = "file:///" + str(file_path).replace("\\", "/")
|
||||
return await self.render(
|
||||
file_path, target, index, params or {}, other_function, timeout
|
||||
)
|
||||
|
||||
async def render_with_persistent_page(
|
||||
self,
|
||||
page_id: str,
|
||||
url: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
page = await WebRenderer.get_persistent_page(page_id, url)
|
||||
screenshot = await self.inner_render(
|
||||
page, url, target, 0, params, other_function, timeout
|
||||
)
|
||||
return screenshot
|
||||
|
||||
async def inner_render(
|
||||
self,
|
||||
page: Page,
|
||||
url: str,
|
||||
target: str,
|
||||
index: int = 0,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
logger.debug(f"Navigating to {url} with timeout {timeout}")
|
||||
url_with_params = url + (
|
||||
"?" + "&".join(f"{k}={v}" for k, v in params.items()) if params else ""
|
||||
)
|
||||
await page.goto(url_with_params, timeout=timeout * 1000, wait_until="load")
|
||||
logger.debug("Page loaded successfully")
|
||||
# 等待目标元素出现
|
||||
await page.wait_for_selector(target, timeout=timeout * 1000)
|
||||
logger.debug(f"Target element '{target}' found, taking screenshot")
|
||||
if other_function:
|
||||
await other_function(page)
|
||||
elements = await page.query_selector_all(target)
|
||||
if not elements:
|
||||
logger.warning(f"Target element '{target}' not found on the page.")
|
||||
elements = await page.query_selector_all('body')
|
||||
if index >= len(elements):
|
||||
logger.warning(f"Index {index} out of range for elements matching '{target}'")
|
||||
index = 0
|
||||
element = elements[index]
|
||||
screenshot = await element.screenshot()
|
||||
logger.debug("Screenshot taken successfully")
|
||||
return screenshot
|
||||
|
||||
|
||||
class LocalPlaywrightInstance(PlaywrightInstance):
|
||||
def __init__(self):
|
||||
self._playwright: Playwright | None = None
|
||||
self._browser: Browser | None = None
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def playwright(self) -> Playwright:
|
||||
assert self._playwright is not None
|
||||
return self._playwright
|
||||
|
||||
@property
|
||||
def browser(self) -> Browser:
|
||||
assert self._browser is not None
|
||||
return self._browser
|
||||
|
||||
async def init(self):
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self.playwright.chromium.launch(headless=True)
|
||||
|
||||
@classmethod
|
||||
async def create(cls) -> "WebRendererInstance":
|
||||
instance = cls()
|
||||
await instance.init()
|
||||
return instance
|
||||
|
||||
async def close(self):
|
||||
await self.browser.close()
|
||||
await self.playwright.stop()
|
||||
|
||||
|
||||
class RemotePlaywrightInstance(PlaywrightInstance):
|
||||
def __init__(self, ws_endpoint: str) -> None:
|
||||
self._playwright: Playwright | None = None
|
||||
self._browser: Browser | None = None
|
||||
self._ws_endpoint = ws_endpoint
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def playwright(self) -> Playwright:
|
||||
assert self._playwright is not None, "Playwright must be initialized by calling init()."
|
||||
return self._playwright
|
||||
|
||||
@property
|
||||
def browser(self) -> Browser:
|
||||
assert self._browser is not None, "Browser must be connected by calling init()."
|
||||
return self._browser
|
||||
|
||||
async def init(self):
|
||||
logger.info(f"尝试连接远程 Playwright 服务器: {self._ws_endpoint}")
|
||||
self._playwright = await async_playwright().start()
|
||||
try:
|
||||
self._browser = await self.playwright.chromium.connect(
|
||||
self._ws_endpoint
|
||||
)
|
||||
logger.info("成功连接到远程 Playwright 服务器。")
|
||||
except PlaywrightError as e:
|
||||
await self.playwright.stop()
|
||||
raise ConnectionError(
|
||||
f"无法连接到远程 Playwright 服务器 ({self._ws_endpoint}):{e}"
|
||||
) from e
|
||||
|
||||
@classmethod
|
||||
async def create(cls, ws_endpoint: str) -> "RemotePlaywrightInstance":
|
||||
"""
|
||||
创建并初始化远程 Playwright 实例的工厂方法。
|
||||
"""
|
||||
instance = cls(ws_endpoint)
|
||||
await instance.init()
|
||||
return instance
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
断开与远程浏览器的连接并停止本地 Playwright 实例。
|
||||
"""
|
||||
if self._browser:
|
||||
await self.browser.close()
|
||||
if self._playwright:
|
||||
await self.playwright.stop()
|
||||
print("已断开远程连接,本地 Playwright 实例已停止。")
|
||||
|
||||
66
konabot/common/web_render/host_images.py
Normal file
@ -0,0 +1,66 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
import nanoid
|
||||
import nonebot
|
||||
|
||||
from nonebot.drivers.fastapi import Driver as FastAPIDriver
|
||||
|
||||
from .config import web_render_config
|
||||
|
||||
app = cast(FastAPIDriver, nonebot.get_driver()).asgi
|
||||
|
||||
hosted_tempdirs: dict[str, Path] = {}
|
||||
hosted_tempdirs_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TempDir:
|
||||
path: Path
|
||||
url_base: str
|
||||
|
||||
def url_of(self, file: Path):
|
||||
assert file.is_relative_to(self.path)
|
||||
relative_path = file.relative_to(self.path)
|
||||
url_path_segment = str(relative_path).replace("\\", "/")
|
||||
return f"{self.url_base}/{url_path_segment}"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def host_tempdir():
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
fp = Path(tempdir)
|
||||
nid = nanoid.generate(size=10)
|
||||
async with hosted_tempdirs_lock:
|
||||
hosted_tempdirs[nid] = fp
|
||||
yield TempDir(
|
||||
path=fp,
|
||||
url_base=f"{web_render_config.get_instance_baseurl()}/tempdir/{nid}",
|
||||
)
|
||||
async with hosted_tempdirs_lock:
|
||||
del hosted_tempdirs[nid]
|
||||
|
||||
|
||||
@app.get("/tempdir/{nid}/{file_path:path}")
|
||||
async def _(nid: str, file_path: str):
|
||||
async with hosted_tempdirs_lock:
|
||||
base_path = hosted_tempdirs.get(nid)
|
||||
if base_path is None:
|
||||
raise HTTPException(404)
|
||||
full_path = base_path / file_path
|
||||
try:
|
||||
if not full_path.resolve().is_relative_to(base_path.resolve()):
|
||||
raise HTTPException(status_code=403, detail="Access denied.")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=403, detail="Access denied.")
|
||||
if not full_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found.")
|
||||
|
||||
return FileResponse(full_path.resolve())
|
||||
|
||||
11
konabot/docs/concepts/中间答案.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# 关于「中间答案」或者「提示」
|
||||
|
||||
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
|
||||
|
||||
- 纯文本的完整匹配:你设置的 pattern 如果和提交的答案完全相等,则会触发提示。
|
||||
- regex 匹配:你设置的 pattern 如果以斜杠(/)开头和结尾,就会检查提交的答案是否匹配正则表达式。注意 ^ 和 $ 符号的使用。
|
||||
- 例如:/^commit$/ 会匹配 commit,不会匹配 acommit、Commit 等。
|
||||
- 而如果是 /commit/,则会匹配 commit、acommit,而不会匹配 Commit。
|
||||
- 无法使用 Javascript 的字符串声明模式,例如,/case_insensitive/i 就不会被视作一个正则表达式。
|
||||
|
||||
一个提示是提示,还是中间答案,取决于它是否有 checkpoint 标记。如果有 checkpoint 标记,则会提示用户「你回答了一个中间答案」,并且这个中间答案的回答会在排行榜中显示。
|
||||
3
konabot/docs/concepts/罗文.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# 关于罗文和洛温
|
||||
|
||||
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。
|
||||
@ -1,45 +1,48 @@
|
||||
指令介绍
|
||||
is_admin - 用于判断当前事件是否来自管理员的内部权限校验函数
|
||||
# 指令介绍
|
||||
|
||||
格式
|
||||
from konabot.common.nb.is_admin import is_admin
|
||||
from nonebot import on
|
||||
from nonebot.adapters import Event
|
||||
from loguru import logger
|
||||
`is_admin` - 用于判断当前事件是否来自管理员的内部权限校验函数
|
||||
|
||||
@on().handle()
|
||||
async def _(event: Event):
|
||||
if is_admin(event):
|
||||
logger.info("管理员发送了消息")
|
||||
# 格式
|
||||
|
||||
说明
|
||||
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
||||
```python
|
||||
from konabot.common.nb.is_admin import is_admin
|
||||
from nonebot import on
|
||||
from nonebot.adapters import Event
|
||||
from loguru import logger
|
||||
|
||||
@on().handle()
|
||||
async def _(event: Event):
|
||||
if is_admin(event):
|
||||
logger.info("管理员发送了消息")
|
||||
```
|
||||
|
||||
# 说明
|
||||
|
||||
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
||||
|
||||
支持的适配器与判定逻辑:
|
||||
• OneBot V11(QQ)
|
||||
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
||||
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
||||
|
||||
• Discord
|
||||
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
||||
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
||||
- OneBot V11(QQ)
|
||||
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
||||
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
||||
- Discord
|
||||
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
||||
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
||||
- Console(控制台)
|
||||
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
||||
|
||||
• Console(控制台)
|
||||
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
||||
# 配置项(位于插件配置中)
|
||||
|
||||
配置项(位于插件配置中)
|
||||
ADMIN_QQ_GROUP: list[int]
|
||||
允许的管理员 QQ 群 ID 列表
|
||||
- `ADMIN_QQ_GROUP`: `list[int]`
|
||||
- 允许的管理员 QQ 群 ID 列表
|
||||
- `ADMIN_QQ_ACCOUNT`: `list[int]`
|
||||
- 允许的管理员 QQ 账号 ID 列表
|
||||
- `ADMIN_DISCORD_CHANNEL`: `list[int]`
|
||||
- 允许的管理员 Discord 频道 ID 列表
|
||||
- `ADMIN_DISCORD_ACCOUNT`: `list[int]`
|
||||
- 允许的管理员 Discord 用户 ID 列表
|
||||
|
||||
ADMIN_QQ_ACCOUNT: list[int]
|
||||
允许的管理员 QQ 账号 ID 列表
|
||||
# 注意事项
|
||||
|
||||
ADMIN_DISCORD_CHANNEL: list[int]
|
||||
允许的管理员 Discord 频道 ID 列表
|
||||
|
||||
ADMIN_DISCORD_ACCOUNT: list[int]
|
||||
允许的管理员 Discord 用户 ID 列表
|
||||
|
||||
注意事项
|
||||
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
||||
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
||||
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
||||
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
||||
|
||||
5
konabot/docs/sys/konaph.txt
Normal file
@ -0,0 +1,5 @@
|
||||
# 指令介绍
|
||||
|
||||
`konaph` - KonaBot 的 PuzzleHunt 管理工具
|
||||
|
||||
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)
|
||||
@ -1,9 +0,0 @@
|
||||
指令介绍
|
||||
怪话过滤 - 去除含有关键词的怪话
|
||||
|
||||
使用方法
|
||||
`怪话过滤 说的道理`
|
||||
去除所有含有“说的道理”的怪话
|
||||
|
||||
另见
|
||||
怪话(1)
|
||||
79
konabot/docs/user/fx.txt
Normal file
@ -0,0 +1,79 @@
|
||||
## 指令介绍
|
||||
|
||||
`fx` - 用于对图片应用各种滤镜效果的指令
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
fx [滤镜名称] <参数1> <参数2> ...
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- `fx 模糊`
|
||||
- `fx 阈值 150`
|
||||
- `fx 缩放 2.0`
|
||||
- `fx 色彩 1.8`
|
||||
- `fx 色键 rgb(0,255,0) 50`
|
||||
|
||||
## 可用滤镜列表
|
||||
|
||||
### 基础滤镜
|
||||
* ```fx 轮廓```
|
||||
* ```fx 锐化```
|
||||
* ```fx 边缘增强```
|
||||
* ```fx 浮雕```
|
||||
* ```fx 查找边缘```
|
||||
* ```fx 平滑```
|
||||
* ```fx 暗角 <半径=1.5>```
|
||||
* ```fx 发光 <强度=0.5> <模糊半径=15>```
|
||||
* ```fx 噪点 <数量=0.05>```
|
||||
* ```fx 素描```
|
||||
* ```fx 阴影 <x偏移量=10> <y偏移量=10> <模糊量=10> <不透明度=0.5> <阴影颜色=black>```
|
||||
|
||||
### 模糊滤镜
|
||||
* ```fx 模糊 <半径=10>```
|
||||
* ```fx 马赛克 <像素大小=10>```
|
||||
* ```fx 径向模糊 <强度=3.0> <采样量=6>```
|
||||
* ```fx 旋转模糊 <强度=30.0> <采样量=6>```
|
||||
* ```fx 方向模糊 <角度=0.0> <距离=20> <采样量=6>```
|
||||
* ```fx 缩放模糊 <强度=0.1> <采样量=6>```
|
||||
* ```fx 边缘模糊 <半径=10.0>```
|
||||
|
||||
### 色彩处理滤镜
|
||||
* ```fx 反色```
|
||||
* ```fx 黑白```
|
||||
* ```fx 阈值 <阈值=128>```
|
||||
* ```fx 对比度 <因子=1.5>```
|
||||
* ```fx 亮度 <因子=1.5>```
|
||||
* ```fx 色彩 <因子=1.5>```
|
||||
* ```fx 色调 <颜色="rgb(255,0,0)">```
|
||||
* ```fx RGB分离 <偏移量=5>```
|
||||
* ```fx 叠加颜色 <颜色列表=[rgb(255,0,0)|(0,0),rgb(0,255,0)|(0,100),rgb(0,0,255)|(50,100)]> <叠加模式=overlay>```
|
||||
* ```fx 像素抖动 <最大偏移量=2>```
|
||||
|
||||
### 几何变换滤镜
|
||||
* ```fx 平移 <x偏移量=10> <y偏移量=10>```
|
||||
* ```fx 缩放 <比例(X)=1.5> <比例Y=None>```
|
||||
* ```fx 旋转 <角度=45>```
|
||||
* ```fx 透视变换 <变换矩阵>```
|
||||
* ```fx 裁剪 <左=0> <上=0> <右=100> <下=100>(百分比)```
|
||||
* ```fx 拓展边缘 <拓展量=10>```
|
||||
* ```fx 波纹 <振幅=5> <波长=20>```
|
||||
* ```fx 光学补偿 <数量=100> <反转=false>```
|
||||
* ```fx 球面化 <强度=0.5>```
|
||||
* ```fx 镜像 <角度=90>```
|
||||
* ```fx 水平翻转```
|
||||
* ```fx 垂直翻转```
|
||||
* ```fx 复制 <目标位置=(100,100)> <缩放=1.0> <源区域=(0,0,100,100)>(百分比)```
|
||||
|
||||
### 特殊效果滤镜
|
||||
* ```fx 色键 <目标颜色="rgb(255,0,0)"> <容差=60>```
|
||||
* ```fx 晃动 <最大偏移量=5> <运动模糊=False>```
|
||||
* ```fx 动图 <帧率=10>```
|
||||
|
||||
## 颜色名称支持
|
||||
- **基本颜色**:红、绿、蓝、黄、紫、黑、白、橙、粉、灰、青、靛、棕
|
||||
- **修饰词**:浅、深、亮、暗(可组合使用,如`浅红`、`深蓝`)
|
||||
- **RGB格式**:`rgb(255,0,0)`、`rgb(0,255,0)`、`(255,0,0)` 等
|
||||
- **HEX格式**:`#66ccff`等
|
||||
@ -1,59 +1,83 @@
|
||||
指令介绍
|
||||
giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
||||
# giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
||||
|
||||
格式
|
||||
giftool [图片] [选项]
|
||||
## 格式
|
||||
|
||||
示例
|
||||
回复一张 GIF 并发送:
|
||||
`giftool --ss 1.5 -t 2.0`
|
||||
从 1.5 秒处开始,截取 2 秒长度的片段。
|
||||
```bash
|
||||
giftool [图片] [选项]
|
||||
```
|
||||
|
||||
`giftool [图片] --ss 0:10 -to 0:15`
|
||||
截取从 10 秒到 15 秒之间的片段(支持 MM:SS 或 HH:MM:SS 格式)。
|
||||
## 示例
|
||||
|
||||
`giftool [图片] --frames:v 10`
|
||||
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
||||
- **回复一张 GIF 并发送:**
|
||||
|
||||
`giftool [图片] --ss 2 --frames:v 5`
|
||||
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
||||
```bash
|
||||
giftool --ss 1.5 -t 2.0
|
||||
```
|
||||
|
||||
参数说明
|
||||
图片(必需)
|
||||
- 必须是 GIF 动图。
|
||||
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
||||
从 1.5 秒处开始,截取 2 秒长度的片段。
|
||||
|
||||
--ss <时间戳>(可选)
|
||||
- 指定开始时间(单位:秒),可使用以下格式:
|
||||
• 纯数字(如 `1.5` 表示 1.5 秒)
|
||||
• 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
||||
• 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
||||
- 默认从开头开始(0 秒)。
|
||||
- ```bash
|
||||
giftool [图片] --ss 0:10 -to 0:15
|
||||
```
|
||||
|
||||
-t <持续时间>(可选)
|
||||
- 指定截取的持续时间(单位:秒),格式同 --ss。
|
||||
- 与 --ss 配合使用:截取 [ss, ss + t] 区间。
|
||||
- 不能与 --to 同时使用。
|
||||
截取从 10 秒到 15 秒之间的片段(支持 `MM:SS` 或 `HH:MM:SS` 格式)。
|
||||
|
||||
--to <时间戳>(可选)
|
||||
- 指定结束时间(单位:秒),格式同 --ss。
|
||||
- 与 --ss 配合使用:截取 [ss, to] 区间。
|
||||
- 不能与 -t 同时使用。
|
||||
- ```bash
|
||||
giftool [图片] --frames:v 10
|
||||
```
|
||||
|
||||
--frames:v <帧数>(可选)
|
||||
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
||||
- 帧数必须为正整数(> 0)。
|
||||
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
||||
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
||||
|
||||
--speed <速度>(可选)
|
||||
- 调整 gif 图的速度。若为负数,则代表倒放
|
||||
- ```bash
|
||||
giftool [图片] --ss 2 --frames:v 5
|
||||
```
|
||||
|
||||
使用方式
|
||||
1. 发送指令前,请确保:
|
||||
- 消息中附带一张 GIF 动图,或
|
||||
- 回复一条包含 GIF 动图的消息后再发送指令。
|
||||
2. 插件会自动:
|
||||
- 解析 GIF 的每一帧及其持续时间(duration)
|
||||
- 根据时间参数转换为帧索引进行裁剪
|
||||
- 如指定抽帧,则对裁剪后的片段均匀采样
|
||||
- 生成新的 GIF 并保持原始循环设置(loop=0)
|
||||
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 图片(必需)
|
||||
|
||||
- 必须是 GIF 动图。
|
||||
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
||||
|
||||
### `--ss <时间戳>`(可选)
|
||||
|
||||
- 指定开始时间(单位:秒),可使用以下格式:
|
||||
- 纯数字(如 `1.5` 表示 1.5 秒)
|
||||
- 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
||||
- 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
||||
- 默认从开头开始(0 秒)。
|
||||
|
||||
### `-t <持续时间>`(可选)
|
||||
|
||||
- 指定截取的持续时间(单位:秒),格式同 `--ss`。
|
||||
- 与 `--ss` 配合使用:截取 `[ss, ss + t]` 区间。
|
||||
- **不能与 `--to` 同时使用。**
|
||||
|
||||
### `--to <时间戳>`(可选)
|
||||
|
||||
- 指定结束时间(单位:秒),格式同 `--ss`。
|
||||
- 与 `--ss` 配合使用:截取 `[ss, to]` 区间。
|
||||
- **不能与 `-t` 同时使用。**
|
||||
|
||||
### `--frames:v <帧数>`(可选)
|
||||
|
||||
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
||||
- 帧数必须为正整数(> 0)。
|
||||
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
||||
|
||||
### `--speed <速度>`(可选)
|
||||
|
||||
- 调整 GIF 图的速度。若为负数,则代表倒放。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 发送指令前,请确保:
|
||||
- 消息中附带一张 GIF 动图,**或**
|
||||
- 回复一条包含 GIF 动图的消息后再发送指令。
|
||||
2. 插件会自动:
|
||||
- 解析 GIF 的每一帧及其持续时间(duration)
|
||||
- 根据时间参数转换为帧索引进行裁剪
|
||||
- 如指定抽帧,则对裁剪后的片段均匀采样
|
||||
- 生成新的 GIF 并保持原始循环设置(`loop=0`)
|
||||
|
||||
@ -1,20 +1,33 @@
|
||||
指令介绍
|
||||
man - 用于展示此方 BOT 使用手册的指令
|
||||
# 指令介绍
|
||||
|
||||
格式
|
||||
man 文档类型
|
||||
man [文档类型] <指令>
|
||||
`man` - 用于展示此方 BOT 使用手册的指令
|
||||
|
||||
示例
|
||||
`man` 查看所有有文档的指令清单
|
||||
`man 3` 列举所有可读文档的库函数清单
|
||||
`man 喵` 查看指令「喵」的使用说明
|
||||
`man 8 out` 查看管理员指令「out」的使用说明
|
||||
## 格式
|
||||
|
||||
文档类型
|
||||
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为这些种类:
|
||||
```
|
||||
man 文档类型
|
||||
man [文档类型] <指令>
|
||||
```
|
||||
|
||||
- 1 用户态指令,用于日常使用的指令
|
||||
- 3 库函数指令,用于 Bot 开发用的函数查询
|
||||
- 7 概念指令,用于概念解释
|
||||
- 8 系统指令,仅管理员可用
|
||||
## 示例
|
||||
|
||||
- ``man``
|
||||
查看所有有文档的指令清单
|
||||
|
||||
- ``man 3``
|
||||
列举所有可读文档的库函数清单
|
||||
|
||||
- ``man 喵``
|
||||
查看指令「喵」的使用说明
|
||||
|
||||
- ``man 8 out``
|
||||
查看管理员指令「out」的使用说明
|
||||
|
||||
## 文档类型
|
||||
|
||||
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为以下种类:
|
||||
|
||||
- **1** 用户态指令:用于日常使用的指令
|
||||
- **3** 库函数指令:用于 Bot 开发用的函数查询
|
||||
- **7** 概念指令:用于概念解释
|
||||
- **8** 系统指令:仅管理员可用
|
||||
|
||||
16
konabot/docs/user/ntfy.txt
Normal file
@ -0,0 +1,16 @@
|
||||
## 指令介绍
|
||||
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的代办事项。
|
||||
|
||||
## 指令示例
|
||||
|
||||
- **`ntfy 创建`**
|
||||
创建一个随机的 ntfy 订阅主题来提醒代办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP,或者使用网页版 ntfy。
|
||||
|
||||
- **`ntfy 创建 kagami-notice`**
|
||||
创建一个名称包含 `kagami-notice` 的 ntfy 订阅主题。
|
||||
|
||||
- **`ntfy 删除`**
|
||||
清除配置,不再使用 ntfy 向你发送通知。
|
||||
|
||||
## 另见
|
||||
[`提醒我(1)`](#) [`查询提醒(1)`](#) [`删除提醒(1)`](#)
|
||||
@ -1,21 +1,39 @@
|
||||
指令介绍
|
||||
openssl - 用于生成指定长度的加密安全随机数据
|
||||
# 指令介绍
|
||||
|
||||
格式
|
||||
openssl rand <模式> <字节数>
|
||||
`openssl rand` — 用于生成指定长度的加密安全随机数据。
|
||||
|
||||
示例
|
||||
`openssl rand -hex 16` 生成 16 字节的十六进制随机数
|
||||
`openssl rand -base64 32` 生成 32 字节并以 Base64 编码输出的随机数据
|
||||
## 格式
|
||||
|
||||
说明
|
||||
该指令使用 Python 的 secrets 模块生成加密安全的随机字节,并支持以十六进制(-hex)或 Base64(-base64)格式输出。
|
||||
```bash
|
||||
openssl rand <模式> <字节数>
|
||||
```
|
||||
|
||||
参数说明
|
||||
模式(mode)
|
||||
- -hex :以十六进制字符串形式输出随机数据
|
||||
- -base64 :以 Base64 编码字符串形式输出随机数据
|
||||
## 示例
|
||||
|
||||
字节数(num)
|
||||
- 必须为正整数
|
||||
- 最大支持 256 字节
|
||||
- ```bash
|
||||
openssl rand -hex 16
|
||||
```
|
||||
生成 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 """
|
||||
uniform float u_time;
|
||||
uniform float2 u_resolution;
|
||||
## 示例
|
||||
```bash
|
||||
shadertool """
|
||||
uniform float u_time;
|
||||
uniform float2 u_resolution;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
return half4(
|
||||
1.0,
|
||||
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
||||
coord.x / u_resolution.x,
|
||||
1.0
|
||||
);
|
||||
}
|
||||
"""
|
||||
half4 main(float2 coord) {
|
||||
return half4(
|
||||
1.0,
|
||||
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
||||
coord.x / u_resolution.x,
|
||||
1.0
|
||||
);
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
参数说明
|
||||
SkSL 代码(必填)
|
||||
- 类型:字符串(建议用英文双引号包裹)
|
||||
- 内容:符合 SkSL 语法的片段着色器代码,必须包含 `void main()` 函数,并为 `sk_FragColor` 赋值。
|
||||
- 注意:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
||||
## 参数说明
|
||||
|
||||
--width <整数>(可选)
|
||||
- 默认值:320
|
||||
- 作用:输出 GIF 的宽度(像素),必须大于 0。
|
||||
### SkSL 代码(必填)
|
||||
- **类型**:字符串(建议用英文双引号包裹)
|
||||
- **内容**:符合 SkSL 语法的片段着色器代码,必须包含 `main` 函数,并返回 `half4` 类型的颜色值。
|
||||
- **注意**:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
||||
|
||||
--height <整数>(可选)
|
||||
- 默认值:180
|
||||
- 作用:输出 GIF 的高度(像素),必须大于 0。
|
||||
### `--width <整数>`(可选)
|
||||
- **默认值**:`320`
|
||||
- **作用**:输出 GIF 的宽度(像素),必须大于 0。
|
||||
|
||||
--duration <浮点数>(可选)
|
||||
- 默认值:1.0
|
||||
- 作用:动画总时长(秒),必须大于 0。
|
||||
- 限制:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
||||
### `--height <整数>`(可选)
|
||||
- **默认值**:`180`
|
||||
- **作用**:输出 GIF 的高度(像素),必须大于 0。
|
||||
|
||||
--fps <浮点数>(可选)
|
||||
- 默认值:15.0
|
||||
- 作用:每秒帧数,控制动画流畅度,必须大于 0。
|
||||
- 常见值:10(低配流畅)、15(默认)、24/30(电影/视频级)。
|
||||
### `--duration <浮点数>`(可选)
|
||||
- **默认值**:`1.0`
|
||||
- **作用**:动画总时长(秒),必须大于 0。
|
||||
- **限制**:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
||||
|
||||
使用方式
|
||||
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
||||
### `--fps <浮点数>`(可选)
|
||||
- **默认值**:`15.0`
|
||||
- **作用**:每秒帧数,控制动画流畅度,必须大于 0。
|
||||
- **常见值**:
|
||||
- `10`:低配流畅
|
||||
- `15`:默认
|
||||
- `24` / `30`:电影/视频级流畅度
|
||||
|
||||
## 使用方式
|
||||
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
||||
|
||||
24
konabot/docs/user/tqszm.txt
Normal file
@ -0,0 +1,24 @@
|
||||
# tqszm
|
||||
|
||||
引用一条消息,让此方帮你提取首字母。
|
||||
|
||||
例子:
|
||||
|
||||
```
|
||||
John: 11-28 16:50:37
|
||||
谁来总结一下今天的工作?
|
||||
|
||||
Jack: 11-28 16:50:55
|
||||
[引用John的消息] @此方Bot tqszm
|
||||
|
||||
此方Bot: 11-28 16:50:56
|
||||
slzjyxjtdgz?
|
||||
```
|
||||
|
||||
或者,你也可以直接以正常指令的方式调用:
|
||||
|
||||
```
|
||||
@此方Bot 提取首字母 中山大学
|
||||
> zsdx
|
||||
```
|
||||
|
||||
@ -1,41 +1,72 @@
|
||||
指令介绍
|
||||
ytpgif - 生成来回镜像翻转的仿 YTPMV 动图
|
||||
# `ytpgif` 指令说明
|
||||
|
||||
格式
|
||||
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 动图)
|
||||
- 自动缩放至最大边长不超过 256 像素(保持宽高比)
|
||||
- 静态图 → 生成“原图↔镜像”循环动图
|
||||
- 动图 → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图
|
||||
- 保留透明通道(如原图含透明),否则转为 RGB 避免颜色异常
|
||||
- **指定倍速(较快)**
|
||||
```bash
|
||||
ytpgif 2.5
|
||||
```
|
||||
以 2.5 倍速处理图片,生成节奏更快的镜像动图。
|
||||
|
||||
注意事项
|
||||
- 图片过大、格式损坏或网络问题可能导致处理失败。
|
||||
- 动图帧数过多或单帧过短可能无法生成有效输出。
|
||||
- 输出 GIF 最大单段帧数限制为 500 帧,以防资源耗尽。
|
||||
- **指定倍速(较慢)**
|
||||
回复一张图片并发送:
|
||||
```bash
|
||||
ytpgif 0.5
|
||||
```
|
||||
以 0.5 倍速生成慢节奏的镜像动图。
|
||||
|
||||
---
|
||||
|
||||
## 参数说明
|
||||
|
||||
### `倍速`(可选)
|
||||
- **类型**:浮点数
|
||||
- **默认值**:`1.0`
|
||||
- **有效范围**:`0.1 ~ 20.0`
|
||||
|
||||
#### 作用:
|
||||
- **静态图**:控制“原图 ↔ 镜像”切换的速度(值越大,切换越快)。
|
||||
- **GIF 动图**:控制截取原始动图正向与反向片段的时长(值越大,截取的片段越长)。
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
在发送指令前,请确保满足以下任一条件:
|
||||
- 在消息中**直接附带一张图片**,或
|
||||
- **回复一条包含图片的消息**后再发送指令。
|
||||
|
||||
插件将自动执行以下操作:
|
||||
1. 下载并识别图片(支持静态图和 GIF 动图)。
|
||||
2. 自动缩放图像,**最大边长不超过 256 像素**(保持宽高比)。
|
||||
3. 根据图片类型处理:
|
||||
- **静态图** → 生成“原图 ↔ 镜像”循环动图。
|
||||
- **GIF 动图** → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图。
|
||||
4. **保留透明通道**(若原图含透明),否则转为 RGB 模式以避免颜色异常。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ 以下情况可能导致处理失败或效果不佳:
|
||||
- 图片过大、格式损坏或网络问题;
|
||||
- 动图帧数过多或单帧持续时间过短;
|
||||
- 输出 GIF 单段帧数超过 **500 帧**(系统将自动限制以防资源耗尽)。
|
||||
|
||||
9
konabot/docs/user/删除提醒.txt
Normal file
@ -0,0 +1,9 @@
|
||||
## 指令介绍
|
||||
**删除提醒** - 删除在 [`查询提醒(1)`](查询提醒(1)) 中查到的提醒
|
||||
|
||||
## 指令示例
|
||||
`删除提醒 1`
|
||||
在查询提醒后,删除编号为 1 的提醒
|
||||
|
||||
## 另见
|
||||
[`提醒我(1)`](提醒我(1)) [`查询提醒(1)`](查询提醒(1)) [`ntfy(1)`](ntfy(1))
|
||||
@ -1,20 +1,24 @@
|
||||
指令介绍
|
||||
卵总展示 - 让卵总举起你的图片
|
||||
# 指令介绍
|
||||
|
||||
格式
|
||||
<引用图片> 卵总展示 [选项]
|
||||
卵总展示 [选项] <图片>
|
||||
**卵总展示** - 让卵总举起你的图片
|
||||
|
||||
选项
|
||||
`--whiteness <number>` 白度
|
||||
将原图进行指数变换,以调整它的白的程度,默认为 0.0
|
||||
## 格式
|
||||
|
||||
`--black-level <number>` 黑色等级
|
||||
将原图减淡,数值越大越淡,范围 0.0-1.0,默认 0.2
|
||||
```
|
||||
<引用图片> 卵总展示 [选项]
|
||||
卵总展示 [选项] <图片>
|
||||
```
|
||||
|
||||
`--opacity <number>` 不透明度
|
||||
将你的图片叠放在图片上的不透明度,默认为 0.8
|
||||
## 选项
|
||||
|
||||
`--saturation <number>` 饱和度
|
||||
调整原图的饱和度,应该要大于 0.0,默认为 0.85
|
||||
- `--whiteness <number>` **白度**
|
||||
将原图进行指数变换,以调整它的白的程度,默认为 `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 +0,0 @@
|
||||
指令介绍
|
||||
说点怪话/说些怪话 - 让 BOT 学群友胡言乱语
|
||||
|
||||
适用范围
|
||||
为保证安全,只有少数授权的群聊可以使用该指令
|
||||
|
||||
使用方法
|
||||
`说点怪话 今天吃什么`
|
||||
期待 Bot 会回答你什么吧
|
||||
|
||||
`说些怪话 明天不想上体育课`
|
||||
Bot 会回复你三句怪话
|
||||
@ -1,12 +1,16 @@
|
||||
指令介绍
|
||||
投票 - 参与已发起的投票
|
||||
## 指令介绍
|
||||
**投票** - 参与已发起的投票
|
||||
|
||||
格式
|
||||
投票 <投票ID/标题> <选项文本>
|
||||
## 格式
|
||||
```
|
||||
投票 <投票ID/标题> <选项文本>
|
||||
```
|
||||
|
||||
示例
|
||||
`投票 1 A` 在ID为1的投票中,投给“A”
|
||||
`投票 这是一个投票 B` 在标题为“这是一个投票”的投票中,投给“B”
|
||||
## 示例
|
||||
- `投票 1 A`
|
||||
在 ID 为 1 的投票中,投给 “A”
|
||||
- `投票 这是一个投票 B`
|
||||
在标题为 “这是一个投票” 的投票中,投给 “B”
|
||||
|
||||
说明
|
||||
目前不支持单人多投,每个人只能投一项。
|
||||
## 说明
|
||||
目前不支持单人多投,每个人只能投一项。
|
||||
|
||||
18
konabot/docs/user/提醒我.txt
Normal file
@ -0,0 +1,18 @@
|
||||
## 指令介绍
|
||||
|
||||
**提醒我** - 在指定的时间提醒人事项的工具
|
||||
|
||||
## 使用示例
|
||||
|
||||
- `下午五点提醒我吃饭`
|
||||
创建一个下午五点的提醒,提醒你吃饭
|
||||
|
||||
- `两分钟后提醒我睡觉`
|
||||
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
|
||||
|
||||
- `2026年4月25日20点整提醒我生日快乐`
|
||||
创建一个指定日期和时间的提醒
|
||||
|
||||
## 另见
|
||||
|
||||
[`查询提醒(1)`](查询提醒) [`删除提醒(1)`](删除提醒) [`ntfy(1)`](ntfy)
|
||||
@ -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 的投票
|
||||
|
||||
- `查看投票 这是一个投票`
|
||||
查看标题为“这是一个投票”的投票
|
||||
|
||||
## 说明
|
||||
|
||||
- 投票进行中时,使用此命令可查看投票的各个选项;
|
||||
- 投票结束后,可查看各选项的最终票数。
|
||||
|
||||
9
konabot/docs/user/查询提醒.txt
Normal file
@ -0,0 +1,9 @@
|
||||
# 指令介绍
|
||||
**查询提醒** - 查询已经创建的提醒
|
||||
|
||||
## 指令格式
|
||||
- `查询提醒`:查询提醒
|
||||
- `查询提醒 2`:查询第二页提醒
|
||||
|
||||
## 另见
|
||||
[提醒我(1)]() [删除提醒(1)]() [ntfy(1)]()
|
||||
@ -1,8 +1,17 @@
|
||||
指令介绍
|
||||
生成二维码 - 将文本内容转换为二维码
|
||||
## 指令介绍
|
||||
|
||||
格式
|
||||
生成二维码 <文本内容>
|
||||
**生成二维码** - 将文本内容转换为二维码
|
||||
|
||||
示例
|
||||
`生成二维码 嗨嗨嗨` 生成扫描结果为“嗨嗨嗨”的二维码图片
|
||||
### 格式
|
||||
|
||||
```
|
||||
生成二维码 <文本内容>
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
生成二维码 嗨嗨嗨
|
||||
```
|
||||
|
||||
生成扫描结果为“嗨嗨嗨”的二维码图片
|
||||
|
||||
30
konabot/docs/user/订阅.txt
Normal file
@ -0,0 +1,30 @@
|
||||
# 指令介绍
|
||||
|
||||
**订阅** - 收听此方 BOT 的自动消息发送。
|
||||
|
||||
---
|
||||
|
||||
## 格式
|
||||
|
||||
- `订阅 <频道名称>`
|
||||
- `取消订阅 <频道名称>`
|
||||
- `查询订阅 [页码]`
|
||||
- `可用订阅 [页码]`
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
- **`订阅 此方谜题`**
|
||||
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
|
||||
- 如果你是私聊,则能够每天发送此方谜题到你的私聊;
|
||||
- 如果在群聊中使用该指令,则会每天发送题目到这个群里面。
|
||||
|
||||
- **`取消订阅 此方谜题`**
|
||||
取消订阅「此方谜题」频道。
|
||||
|
||||
- **`查询订阅`**
|
||||
查询当前聊天上下文订阅的所有频道。
|
||||
|
||||
- **`可用订阅 2`**
|
||||
查询所有可用的订阅的第二页。
|
||||
@ -1,13 +1,20 @@
|
||||
指令介绍
|
||||
雷达回波 - 用于获取指定地区的天气雷达回波图像
|
||||
# 指令介绍
|
||||
|
||||
格式
|
||||
雷达回波 <地区>
|
||||
**雷达回波** - 用于获取指定地区的天气雷达回波图像。
|
||||
|
||||
示例
|
||||
`雷达回波 华南` 获取华南地区的天气雷达回波图
|
||||
`雷达回波 全国` 获取全国的天气雷达回波图
|
||||
## 格式
|
||||
|
||||
说明
|
||||
该指令通过查询中国气象局 https://www.nmc.cn/publish/radar/chinaall.html ,获取指定地区的实时天气雷达回波图像。
|
||||
支持的地区有:全国 华北 东北 华东 华中 华南 西南 西北。
|
||||
```
|
||||
雷达回波 <地区>
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
- `雷达回波 华南`:获取华南地区的天气雷达回波图
|
||||
- `雷达回波 全国`:获取全国的天气雷达回波图
|
||||
|
||||
## 说明
|
||||
|
||||
该指令通过查询中国气象局 [https://www.nmc.cn/publish/radar/chinaall.html](https://www.nmc.cn/publish/radar/chinaall.html),获取指定地区的实时天气雷达回波图像。
|
||||
|
||||
支持的地区有:**全国**、**华北**、**东北**、**华东**、**华中**、**华南**、**西南**、**西北**。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
指令介绍
|
||||
黑白 - 将图片经过一个黑白滤镜的处理
|
||||
## 指令介绍
|
||||
|
||||
示例
|
||||
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
||||
**黑白** - 将图片经过一个黑白滤镜的处理
|
||||
|
||||
## 示例
|
||||
|
||||
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
||||
|
||||
228
konabot/plugins/air_conditioner/__init__.py
Normal file
@ -0,0 +1,228 @@
|
||||
from io import BytesIO
|
||||
from typing import Optional, Union
|
||||
import cv2
|
||||
import nonebot
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from konabot.common.database import DatabaseManager
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
from konabot.common.web_render import WebRenderer
|
||||
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
|
||||
from pathlib import Path
|
||||
import random
|
||||
import math
|
||||
|
||||
ROOT_PATH = Path(__file__).resolve().parent
|
||||
|
||||
# 创建全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
async def get_ac(id: str) -> AirConditioner:
|
||||
ac = await AirConditioner.get_ac(id)
|
||||
if ac is None:
|
||||
ac = AirConditioner(id)
|
||||
return ac
|
||||
|
||||
async def send_ac_image(event: type[AlconnaMatcher], ac: AirConditioner):
|
||||
if(ac.burnt == True):
|
||||
# 打开坏掉的空调图片
|
||||
with open(ASSETS_PATH / "img" / "ac" / "broken_ac.png", "rb") as f:
|
||||
# 将其转为 GIF 格式发送
|
||||
output = BytesIO()
|
||||
Image.open(f).save(output, format="GIF")
|
||||
output.seek(0)
|
||||
await event.send(await UniMessage().image(raw=output).export())
|
||||
return
|
||||
if(ac.frozen == True):
|
||||
# 打开坏掉的空调图片
|
||||
with open(ASSETS_PATH / "img" / "ac" / "frozen_ac.png", "rb") as f:
|
||||
# 将其转为 GIF 格式发送
|
||||
output = BytesIO()
|
||||
Image.open(f).save(output, format="GIF")
|
||||
output.seek(0)
|
||||
await event.send(await UniMessage().image(raw=output).export())
|
||||
return
|
||||
ac_image = await generate_ac_image(ac)
|
||||
await event.send(await UniMessage().image(raw=ac_image).export())
|
||||
|
||||
|
||||
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(
|
||||
"群空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = await get_ac(id)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"开空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = await get_ac(id)
|
||||
await ac.update_ac(state=True)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"关空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = await get_ac(id)
|
||||
await ac.update_ac(state=False)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调升温",
|
||||
Args["temp?", Optional[Union[int, float]]] # 可选参数,升温的度数,默认为1
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||
if temp is None:
|
||||
temp = 1
|
||||
if temp <= 0:
|
||||
return
|
||||
id = target.channel_id
|
||||
ac = await get_ac(id)
|
||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||
await send_ac_image(evt, ac)
|
||||
return
|
||||
await ac.update_ac(temperature_delta=temp)
|
||||
if ac.burnt:
|
||||
# 打开爆炸图片
|
||||
with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f:
|
||||
output = BytesIO()
|
||||
# 爆炸抖动
|
||||
frames = wiggle_transform(np.array(Image.open(f)), intensity=5)
|
||||
pil_frames = [Image.fromarray(frame) for frame in frames]
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=35, disposal=2)
|
||||
output.seek(0)
|
||||
await evt.send(await UniMessage().image(raw=output).export())
|
||||
await evt.send("太热啦,空调炸了!")
|
||||
return
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调降温",
|
||||
Args["temp?", Optional[Union[int, float]]] # 可选参数,降温的度数,默认为1
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||
if temp is None:
|
||||
temp = 1
|
||||
if temp <= 0:
|
||||
return
|
||||
id = target.channel_id
|
||||
ac = await get_ac(id)
|
||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||
await send_ac_image(evt, ac)
|
||||
return
|
||||
await ac.update_ac(temperature_delta=-temp)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"换空调"
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = await get_ac(id)
|
||||
await ac.change_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(
|
||||
"空调炸炸排行榜",
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
# ac = get_ac(id)
|
||||
# number, ranking = ac.get_crashes_and_ranking()
|
||||
number, ranking = await query_number_ranking(id)
|
||||
params = {
|
||||
"number": number,
|
||||
"ranking": ranking
|
||||
}
|
||||
image = await WebRenderer.render_file(
|
||||
file_path=ASSETS_PATH / "webpage" / "ac" / "index.html",
|
||||
target=".box",
|
||||
params=params
|
||||
)
|
||||
await evt.send(await UniMessage().image(raw=image).export())
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调最高峰",
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
result = await db_manager.query_by_sql_file(
|
||||
ROOT_PATH / "sql" / "query_peak.sql"
|
||||
)
|
||||
if len(result) == 0:
|
||||
await evt.send("没有空调记录!")
|
||||
return
|
||||
max_temp = result[0].get("max")
|
||||
min_temp = result[0].get("min")
|
||||
his_max = result[0].get("his_max")
|
||||
his_min = result[0].get("his_min")
|
||||
# 再从内存里的空调池中获取最高温度和最低温度
|
||||
for ac in AirConditioner.InstancesPool.values():
|
||||
if ac.on and not ac.burnt and not ac.frozen:
|
||||
if max_temp is None or min_temp is None:
|
||||
max_temp = ac.temperature
|
||||
min_temp = ac.temperature
|
||||
max_temp = max(max_temp, ac.temperature)
|
||||
min_temp = min(min_temp, ac.temperature)
|
||||
if max_temp is None or min_temp is None:
|
||||
await evt.send(f"目前全部空调都被炸掉了!")
|
||||
else:
|
||||
await evt.send(f"全球在线空调最高温度为 {'%.1f' % max_temp}°C,最低温度为 {'%.1f' % min_temp}°C!")
|
||||
if his_max is None or his_min is None:
|
||||
pass
|
||||
else:
|
||||
await evt.send(f"历史最高温度为 {'%.1f' % his_max}°C,最低温度为 {'%.1f' % his_min}°C!\n(要进入历史记录,温度需至少保持 5 分钟)")
|
||||
458
konabot/plugins/air_conditioner/ac.py
Normal file
@ -0,0 +1,458 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
import math
|
||||
from pathlib import Path
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from konabot.common.database import DatabaseManager
|
||||
from konabot.common.path import ASSETS_PATH, FONTS_PATH
|
||||
from konabot.common.path import DATA_PATH
|
||||
import nonebot
|
||||
import json
|
||||
|
||||
ROOT_PATH = Path(__file__).resolve().parent
|
||||
|
||||
# 创建全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
class CrashType(Enum):
|
||||
BURNT = 0
|
||||
FROZEN = 1
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
@driver.on_startup
|
||||
async def register_startup_hook():
|
||||
await ac_manager.start_auto_save()
|
||||
|
||||
@driver.on_shutdown
|
||||
async def register_shutdown_hook():
|
||||
"""注册关闭时需要执行的函数"""
|
||||
# 停止自动保存任务
|
||||
if ac_manager:
|
||||
await ac_manager.stop_auto_save()
|
||||
|
||||
class AirConditionerManager:
|
||||
def __init__(self, save_interval: int = 300): # 默认5分钟保存一次
|
||||
self.save_interval = save_interval
|
||||
self._save_task = None
|
||||
self._running = False
|
||||
|
||||
async def start_auto_save(self):
|
||||
"""启动自动保存任务"""
|
||||
self._running = True
|
||||
self._save_task = asyncio.create_task(self._auto_save_loop())
|
||||
|
||||
logger.info(f"自动保存任务已启动,间隔: {self.save_interval}秒")
|
||||
|
||||
async def stop_auto_save(self):
|
||||
"""停止自动保存任务"""
|
||||
if self._save_task:
|
||||
self._running = False
|
||||
self._save_task.cancel()
|
||||
try:
|
||||
await self._save_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("自动保存任务已停止")
|
||||
else:
|
||||
logger.warning("没有正在运行的自动保存任务")
|
||||
|
||||
async def _auto_save_loop(self):
|
||||
"""自动保存循环"""
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(self.save_interval)
|
||||
await self.save_all_instances()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"定时保存失败: {e}")
|
||||
|
||||
async def save_all_instances(self):
|
||||
save_time = time.time()
|
||||
to_remove = []
|
||||
"""保存所有实例到数据库"""
|
||||
for ac_id, ac_instance in AirConditioner.InstancesPool.items():
|
||||
try:
|
||||
await db_manager.execute_by_sql_file(
|
||||
ROOT_PATH / "sql" / "update_ac.sql",
|
||||
[(ac_instance.on, ac_instance.temperature,
|
||||
ac_instance.burnt, ac_instance.frozen, ac_id),(ac_id,)]
|
||||
)
|
||||
if(save_time - ac_instance.instance_get_time >= 300): # 5 分钟
|
||||
to_remove.append(ac_id)
|
||||
except Exception as e:
|
||||
logger.error(f"保存空调 {ac_id} 失败: {e}")
|
||||
|
||||
logger.info(f"定时保存完成,共保存 {len(AirConditioner.InstancesPool)} 个空调实例")
|
||||
|
||||
# 删除时间过长实例
|
||||
for ac_id in to_remove:
|
||||
del AirConditioner.InstancesPool[ac_id]
|
||||
|
||||
logger.info(f"清理长期不活跃的空调实例完成,目前池内共有 {len(AirConditioner.InstancesPool)} 个实例")
|
||||
|
||||
ac_manager = AirConditionerManager(save_interval=300) # 5分钟
|
||||
|
||||
class AirConditioner:
|
||||
InstancesPool: dict[str, 'AirConditioner'] = {}
|
||||
|
||||
@classmethod
|
||||
async def refresh_ac(cls, id: str):
|
||||
cls.InstancesPool[id].instance_get_time = time.time()
|
||||
|
||||
@classmethod
|
||||
async def storage_ac(cls, id: str, ac: 'AirConditioner'):
|
||||
cls.InstancesPool[id] = ac
|
||||
|
||||
@classmethod
|
||||
async def get_ac(cls, id: str) -> 'AirConditioner':
|
||||
if(id in cls.InstancesPool):
|
||||
await cls.refresh_ac(id)
|
||||
return cls.InstancesPool[id]
|
||||
# 如果没有,那么从数据库重新实例化一个 AC 出来
|
||||
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"])
|
||||
await cls.storage_ac(id, ac)
|
||||
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),(id,)]
|
||||
)
|
||||
await cls.storage_ac(id, ac)
|
||||
return ac
|
||||
|
||||
async def change_ac_temp(self, temperature_delta: float) -> None:
|
||||
'''
|
||||
改变空调的温度
|
||||
:param temperature_delta: float 温度变化量
|
||||
'''
|
||||
changed_temp = self.temperature + temperature_delta
|
||||
random_poss = random.random()
|
||||
if temperature_delta < 0 and changed_temp < 0:
|
||||
# 根据温度随机出是否冻结,0度开始,呈指数增长
|
||||
possibility = -math.e ** (changed_temp / 50) + 1
|
||||
if random_poss < possibility:
|
||||
await self.broke_ac(CrashType.FROZEN)
|
||||
elif temperature_delta > 0 and changed_temp > 40:
|
||||
# 根据温度随机出是否烧坏,40度开始,呈指数增长
|
||||
possibility = -math.e ** ((40-changed_temp) / 50) + 1
|
||||
if random_poss < possibility:
|
||||
await self.broke_ac(CrashType.BURNT)
|
||||
self.temperature = changed_temp
|
||||
|
||||
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:
|
||||
await self.change_ac_temp(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:
|
||||
self.id = id
|
||||
self.on = False
|
||||
self.temperature = 24 # 默认温度
|
||||
self.burnt = False
|
||||
self.frozen = False
|
||||
|
||||
self.instance_get_time = time.time()
|
||||
|
||||
async def broke_ac(self, crash_type: CrashType):
|
||||
'''
|
||||
让空调坏掉
|
||||
:param crash_type: CrashType 枚举,表示空调坏掉的类型
|
||||
'''
|
||||
match crash_type:
|
||||
case CrashType.BURNT:
|
||||
await self.update_ac(burnt=True)
|
||||
case CrashType.FROZEN:
|
||||
await self.update_ac(frozen=True)
|
||||
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):
|
||||
# '''
|
||||
# 如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
|
||||
# '''
|
||||
# data_file = DATA_PATH / "ac_crash_data.json"
|
||||
# crash_data = {}
|
||||
# if data_file.exists():
|
||||
# with open(data_file, "r", encoding="utf-8") as f:
|
||||
# crash_data = json.load(f)
|
||||
# if self.id not in crash_data:
|
||||
# crash_data[self.id] = {"burnt": 0, "frozen": 0}
|
||||
# match crash_type:
|
||||
# case CrashType.BURNT:
|
||||
# crash_data[self.id]["burnt"] += 1
|
||||
# case CrashType.FROZEN:
|
||||
# crash_data[self.id]["frozen"] += 1
|
||||
# with open(data_file, "w", encoding="utf-8") as f:
|
||||
# json.dump(crash_data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
def get_crashes_and_ranking(self) -> tuple[int, int]:
|
||||
'''
|
||||
获取该群在全国空调损坏的数量与排行榜的位置
|
||||
'''
|
||||
data_file = DATA_PATH / "ac_crash_data.json"
|
||||
if not data_file.exists():
|
||||
return 0, 1
|
||||
with open(data_file, "r", encoding="utf-8") as f:
|
||||
crash_data = json.load(f)
|
||||
ranking_list = []
|
||||
for gid, record in crash_data.items():
|
||||
total = record.get("burnt", 0) + record.get("frozen", 0)
|
||||
ranking_list.append((gid, total))
|
||||
ranking_list.sort(key=lambda x: x[1], reverse=True)
|
||||
total_crashes = crash_data.get(self.id, {}).get("burnt", 0) + crash_data.get(self.id, {}).get("frozen", 0)
|
||||
rank = 1
|
||||
for gid, total in ranking_list:
|
||||
if gid == self.id:
|
||||
break
|
||||
rank += 1
|
||||
return total_crashes, rank
|
||||
|
||||
def text_to_transparent_image(text, font_size=40, padding=0, text_color=(0, 0, 0)):
|
||||
"""
|
||||
将文本转换为带透明背景的图像,图像大小刚好包含文本
|
||||
"""
|
||||
# 创建临时图像来计算文本尺寸
|
||||
temp_image = Image.new('RGB', (1, 1), (255, 255, 255))
|
||||
temp_draw = ImageDraw.Draw(temp_image)
|
||||
|
||||
font = ImageFont.truetype(FONTS_PATH / "montserrat.otf", font_size)
|
||||
|
||||
# 获取文本边界框
|
||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# 计算图像大小(文本大小 + 内边距)
|
||||
image_width = int(text_width + 2 * padding)
|
||||
image_height = int(text_height + 2 * padding)
|
||||
|
||||
# 创建RGBA模式的空白图像(带透明通道)
|
||||
image = Image.new('RGBA', (image_width, image_height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 绘制文本(考虑内边距)
|
||||
x = padding - bbox[0] # 调整起始位置
|
||||
y = padding - bbox[1]
|
||||
|
||||
# 设置文本颜色(带透明度)
|
||||
if len(text_color) == 3:
|
||||
text_color = text_color + (255,) # 添加完全不透明的alpha值
|
||||
|
||||
draw.text((x, y), text, fill=text_color, font=font)
|
||||
|
||||
# 转换为OpenCV格式(BGRA)
|
||||
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGBA2BGRA)
|
||||
return image_cv
|
||||
|
||||
def perspective_transform(image, target, corners):
|
||||
"""
|
||||
对图像进行透视变换(保持透明通道)
|
||||
target: 画布
|
||||
corners: 四个角点的坐标,顺序为 [左上, 右上, 右下, 左下]
|
||||
"""
|
||||
height, width = image.shape[:2]
|
||||
|
||||
# 源点(原始图像的四个角)
|
||||
src_points = np.array([
|
||||
[0, 0], # 左上
|
||||
[width-1, 0], # 右上
|
||||
[width-1, height-1], # 右下
|
||||
[0, height-1] # 左下
|
||||
], dtype=np.float32)
|
||||
|
||||
# 目标点(变换后的四个角)
|
||||
dst_points = np.array(corners, dtype=np.float32)
|
||||
|
||||
# 计算透视变换矩阵
|
||||
matrix = cv2.getPerspectiveTransform(src_points, dst_points)
|
||||
|
||||
# 获取画布大小
|
||||
target_height, target_width = target.shape[:2]
|
||||
|
||||
# 应用透视变换(保持所有通道,包括alpha)
|
||||
transformed = cv2.warpPerspective(image, matrix, (target_width, target_height), flags=cv2.INTER_LINEAR)
|
||||
|
||||
return transformed, matrix
|
||||
|
||||
def blend_with_transparency(background, foreground, position):
|
||||
"""
|
||||
将带透明通道的前景图像合成到背景图像上
|
||||
position: 前景图像在背景图像上的位置 (x, y)
|
||||
"""
|
||||
bg = background.copy()
|
||||
|
||||
# 如果背景没有alpha通道,添加一个
|
||||
if bg.shape[2] == 3:
|
||||
bg = cv2.cvtColor(bg, cv2.COLOR_BGR2BGRA)
|
||||
bg[:, :, 3] = 255 # 完全不透明
|
||||
|
||||
x, y = position
|
||||
fg_height, fg_width = foreground.shape[:2]
|
||||
bg_height, bg_width = bg.shape[:2]
|
||||
|
||||
# 确保位置在图像范围内
|
||||
x = max(0, min(x, bg_width - fg_width))
|
||||
y = max(0, min(y, bg_height - fg_height))
|
||||
|
||||
# 提取前景的alpha通道并归一化
|
||||
alpha_foreground = foreground[:, :, 3] / 255.0
|
||||
|
||||
# 对于每个颜色通道进行合成
|
||||
for c in range(3):
|
||||
bg_region = bg[y:y+fg_height, x:x+fg_width, c]
|
||||
fg_region = foreground[:, :, c]
|
||||
|
||||
# alpha混合公式
|
||||
bg[y:y+fg_height, x:x+fg_width, c] = (
|
||||
alpha_foreground * fg_region +
|
||||
(1 - alpha_foreground) * bg_region
|
||||
)
|
||||
|
||||
# 更新背景的alpha通道(如果需要)
|
||||
bg_alpha_region = bg[y:y+fg_height, x:x+fg_width, 3]
|
||||
bg[y:y+fg_height, x:x+fg_width, 3] = np.maximum(bg_alpha_region, foreground[:, :, 3])
|
||||
|
||||
return bg
|
||||
|
||||
def precise_blend_with_perspective(background, foreground, corners):
|
||||
"""
|
||||
精确合成:根据四个角点将前景图像透视合成到背景上
|
||||
"""
|
||||
# 创建与背景相同大小的空白图像
|
||||
bg_height, bg_width = background.shape[:2]
|
||||
|
||||
# 如果背景没有alpha通道,转换为BGRA
|
||||
if background.shape[2] == 3:
|
||||
background_bgra = cv2.cvtColor(background, cv2.COLOR_BGR2BGRA)
|
||||
else:
|
||||
background_bgra = background.copy()
|
||||
|
||||
# 创建与背景相同大小的前景图层
|
||||
foreground_layer = np.zeros((bg_height, bg_width, 4), dtype=np.uint8)
|
||||
|
||||
# 计算前景图像在背景中的边界框
|
||||
min_x = int(min(corners[:, 0]))
|
||||
max_x = int(max(corners[:, 0]))
|
||||
min_y = int(min(corners[:, 1]))
|
||||
max_y = int(max(corners[:, 1]))
|
||||
|
||||
# 将变换后的前景图像放置到对应位置
|
||||
fg_height, fg_width = foreground.shape[:2]
|
||||
if min_y + fg_height <= bg_height and min_x + fg_width <= bg_width:
|
||||
foreground_layer[min_y:min_y+fg_height, min_x:min_x+fg_width] = foreground
|
||||
|
||||
# 创建掩码(只在前景有内容的地方合成)
|
||||
mask = (foreground_layer[:, :, 3] > 0)
|
||||
|
||||
# 合成图像
|
||||
result = background_bgra.copy()
|
||||
for c in range(3):
|
||||
result[:, :, c][mask] = foreground_layer[:, :, c][mask]
|
||||
result[:, :, 3][mask] = foreground_layer[:, :, 3][mask]
|
||||
|
||||
return result
|
||||
|
||||
def wiggle_transform(image, intensity=2) -> list[np.ndarray]:
|
||||
'''
|
||||
返回一组图像振动的帧组,模拟空调运作时的抖动效果
|
||||
'''
|
||||
frames = []
|
||||
height, width = image.shape[:2]
|
||||
shifts = [(-intensity, 0), (intensity, 0), (0, -intensity), (0, intensity), (0, 0)]
|
||||
for dx, dy in shifts:
|
||||
M = np.float32([[1, 0, dx], [0, 1, dy]])
|
||||
shifted = cv2.warpAffine(image, M, (width, height))
|
||||
frames.append(shifted)
|
||||
return frames
|
||||
|
||||
async def generate_ac_image(ac: AirConditioner) -> BytesIO:
|
||||
# 找到空调底图
|
||||
ac_image = cv2.imread(str(ASSETS_PATH / "img" / "ac" / "ac.png"), cv2.IMREAD_UNCHANGED)
|
||||
|
||||
if not ac.on:
|
||||
# 空调关闭状态,直接返回底图
|
||||
pil_final = Image.fromarray(ac_image)
|
||||
output = BytesIO()
|
||||
pil_final.save(output, format="GIF")
|
||||
return output
|
||||
|
||||
# 根据生成温度文本图像
|
||||
text = f"{round(ac.temperature, 1)}°C"
|
||||
text_image = text_to_transparent_image(
|
||||
text,
|
||||
font_size=60,
|
||||
text_color=(0, 0, 0) # 黑色文字
|
||||
)
|
||||
|
||||
# 获取长宽比
|
||||
height, width = text_image.shape[:2]
|
||||
aspect_ratio = width / height
|
||||
|
||||
# 定义3D变换的四个角点(透视效果)
|
||||
# 顺序: [左上, 右上, 右下, 左下]
|
||||
corners = np.array([
|
||||
[123, 45], # 左上
|
||||
[284, 101], # 右上
|
||||
[290, 140], # 右下
|
||||
[119, 100] # 左下
|
||||
], dtype=np.float32)
|
||||
|
||||
# 对文本图像进行3D变换(保持透明通道)
|
||||
transformed_text, transform_matrix = perspective_transform(text_image, ac_image, corners)
|
||||
|
||||
final_image_simple = blend_with_transparency(ac_image, transformed_text, (0, 0))
|
||||
|
||||
intensity = max(2, abs(int(ac.temperature) - 24) // 2)
|
||||
|
||||
frames = wiggle_transform(final_image_simple, intensity=intensity)
|
||||
pil_frames = [Image.fromarray(frame) for frame in frames]
|
||||
output = BytesIO()
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50, disposal=2)
|
||||
return output
|
||||
26
konabot/plugins/air_conditioner/sql/create_table.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- 创建所有表
|
||||
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_log (
|
||||
log_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
log_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
id VARCHAR(128),
|
||||
"on" BOOLEAN NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
burnt BOOLEAN NOT NULL,
|
||||
frozen BOOLEAN NOT NULL,
|
||||
FOREIGN KEY (id) REFERENCES air_conditioner(id)
|
||||
);
|
||||
|
||||
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)
|
||||
);
|
||||
8
konabot/plugins/air_conditioner/sql/insert_ac.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- 插入一台新空调
|
||||
INSERT INTO air_conditioner (id, "on", temperature, burnt, frozen)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
-- 使用返回的数据插入日志
|
||||
INSERT INTO air_conditioner_log (id, "on", temperature, burnt, frozen)
|
||||
SELECT id, "on", temperature, burnt, frozen
|
||||
FROM air_conditioner
|
||||
WHERE id = ?;
|
||||
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
@ -0,0 +1,4 @@
|
||||
-- 查询空调状态
|
||||
SELECT *
|
||||
FROM air_conditioner
|
||||
WHERE id = ?;
|
||||
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 = ?
|
||||
);
|
||||
13
konabot/plugins/air_conditioner/sql/query_peak.sql
Normal file
@ -0,0 +1,13 @@
|
||||
-- 查询目前所有空调中的最高温度与最低温度与历史最高低温
|
||||
SELECT
|
||||
(SELECT MAX(temperature) FROM air_conditioner
|
||||
WHERE "on" = TRUE AND NOT frozen AND NOT burnt) AS max,
|
||||
|
||||
(SELECT MIN(temperature) FROM air_conditioner
|
||||
WHERE "on" = TRUE AND NOT frozen AND NOT burnt) AS min,
|
||||
|
||||
(SELECT MAX(temperature) FROM air_conditioner_log
|
||||
WHERE "on" = TRUE AND NOT frozen AND NOT burnt) AS his_max,
|
||||
|
||||
(SELECT MIN(temperature) FROM air_conditioner_log
|
||||
WHERE "on" = TRUE AND NOT frozen AND NOT burnt) AS his_min;
|
||||
10
konabot/plugins/air_conditioner/sql/update_ac.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- 更新空调状态
|
||||
UPDATE air_conditioner
|
||||
SET "on" = ?, temperature = ?, burnt = ?, frozen = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- 插入日志记录(从更新后的数据获取)
|
||||
INSERT INTO air_conditioner_log (id, "on", temperature, burnt, frozen)
|
||||
SELECT id, "on", temperature, burnt, frozen
|
||||
FROM air_conditioner
|
||||
WHERE id = ?;
|
||||
@ -6,34 +6,25 @@ from nonebot_plugin_alconna import Reference, Reply, UniMsg
|
||||
from nonebot.adapters import Event
|
||||
|
||||
|
||||
matcher_fix = on_message()
|
||||
|
||||
pattern = (
|
||||
r"^(?:(?:av|cv)\d+|BV[a-zA-Z0-9]{10})|"
|
||||
r"(?:b23\.tv|bili(?:22|23|33|2233)\.cn|\.bilibili\.com|QQ小程序(?:&#93;|]|\])哔哩哔哩).{0,500}"
|
||||
)
|
||||
|
||||
|
||||
@matcher_fix.handle()
|
||||
async def _(msg: UniMsg, event: Event):
|
||||
def _rule(msg: UniMsg):
|
||||
to_search = msg.exclude(Reply, Reference).dump(json=True)
|
||||
to_search2 = msg.exclude(Reply, Reference).extract_plain_text()
|
||||
if not re.search(pattern, to_search) and not re.search(pattern, to_search2):
|
||||
return
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
matcher_fix = on_message(rule=_rule)
|
||||
|
||||
@matcher_fix.handle()
|
||||
async def _(event: Event):
|
||||
from nonebot_plugin_analysis_bilibili import handle_analysis
|
||||
|
||||
await handle_analysis(event)
|
||||
|
||||
# b_url: str
|
||||
# b_page: str | None
|
||||
# b_time: str | None
|
||||
#
|
||||
# from nonebot_plugin_analysis_bilibili.analysis_bilibili import extract as bilibili_extract
|
||||
#
|
||||
# b_url, b_page, b_time = bilibili_extract(to_search)
|
||||
# if b_url is None:
|
||||
# return
|
||||
#
|
||||
# await matcher_fix.send(await UniMessage().text(b_url).export())
|
||||
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
import asyncio
|
||||
from nonebot import get_plugin_config, on_command, on_message
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
|
||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, UniMsg, on_alconna
|
||||
from pydantic import BaseModel
|
||||
|
||||
from konabot.common.nb.is_admin import is_admin
|
||||
from konabot.common.path import DATA_PATH
|
||||
|
||||
from .random_text_record import RandomTextManager
|
||||
|
||||
|
||||
class FortuneConfig(BaseModel):
|
||||
plugin_fortune_collect_groups: list[int] = []
|
||||
|
||||
|
||||
fortune_wtf = RandomTextManager(DATA_PATH / "fortune_wtf.txt")
|
||||
fortune_insert_lock = asyncio.Lock()
|
||||
fortune_config = get_plugin_config(FortuneConfig)
|
||||
|
||||
|
||||
async def is_collect_target(evt: GroupMessageEvent) -> bool:
|
||||
if evt.group_id not in fortune_config.plugin_fortune_collect_groups:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
evt_collector = on_message(rule=is_collect_target)
|
||||
|
||||
@evt_collector.handle()
|
||||
async def _(msg: UniMsg):
|
||||
txt = msg.extract_plain_text()
|
||||
if len(txt) > 50:
|
||||
return
|
||||
if txt.startswith("说点怪话") or txt.startswith("说些怪话") or txt.startswith("怪话过滤"):
|
||||
return
|
||||
async with fortune_insert_lock:
|
||||
fortune_wtf.insert(txt)
|
||||
|
||||
|
||||
cmd_guaihua = on_command("说点怪话", rule=is_collect_target)
|
||||
|
||||
@cmd_guaihua.handle()
|
||||
async def _(bot: Bot):
|
||||
await cmd_guaihua.send(await UniMessage().text(fortune_wtf.choice()).export(bot))
|
||||
|
||||
|
||||
cmd_guaihuas = on_command("说些怪话", rule=is_collect_target)
|
||||
|
||||
@cmd_guaihuas.handle()
|
||||
async def _(bot: Bot):
|
||||
for _ in range(3):
|
||||
await cmd_guaihuas.send(await UniMessage().text(fortune_wtf.choice()).export(bot))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
cmd_filter_guaihua = on_alconna(Alconna(
|
||||
"怪话过滤",
|
||||
Args["keyword", str],
|
||||
), rule=is_admin)
|
||||
|
||||
@cmd_filter_guaihua.handle()
|
||||
async def _(keyword: str, bot: Bot):
|
||||
async with fortune_insert_lock:
|
||||
c = fortune_wtf.filter_out(keyword)
|
||||
await cmd_filter_guaihua.send(await UniMessage().text(f"删除了 {c} 条怪话").export(bot))
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
import base64
|
||||
import random
|
||||
import time
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class RandomTextManager:
|
||||
_cache: list[tuple[float, str]]
|
||||
|
||||
def __init__(self, fp: Path) -> None:
|
||||
self.fp = fp
|
||||
self._cache = []
|
||||
if not self.fp.exists():
|
||||
self.fp.touch()
|
||||
else:
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
self._cache = []
|
||||
with self.fp.open("r") as f:
|
||||
for line in f.readlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
if "|" not in line:
|
||||
continue
|
||||
ts, cn = line.split("|")
|
||||
try:
|
||||
ts = float(ts)
|
||||
except Exception:
|
||||
continue
|
||||
self._cache.append((ts, base64.b64decode(cn).decode("utf-8")))
|
||||
|
||||
def save(self):
|
||||
lines = [
|
||||
str(ts) + "|" + base64.b64encode(cn.encode("utf-8")).decode()
|
||||
for ts, cn in self._cache
|
||||
]
|
||||
with self.fp.open("w") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def insert(self, text: str, timestamp: float | None = None):
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
with self.fp.open("a") as f:
|
||||
f.write(str(timestamp) + "|" + base64.b64encode(text.encode("utf-8")).decode() + "\n")
|
||||
self._cache.append((timestamp, text))
|
||||
|
||||
def choice(self, now: float | None = None):
|
||||
contents: list[str] = []
|
||||
weights: list[float] = []
|
||||
|
||||
if now is None:
|
||||
now = time.time()
|
||||
|
||||
for ts, cn in self._cache:
|
||||
contents.append(cn)
|
||||
weights.append((abs(now - ts) + 0.01) ** (-1))
|
||||
|
||||
return random.choices(contents, weights)[0]
|
||||
|
||||
def filter_out(self, keyword: str):
|
||||
len1 = len(self._cache)
|
||||
self._cache = [
|
||||
(ts, cn) for ts, cn in self._cache
|
||||
if keyword not in cn
|
||||
]
|
||||
self.save()
|
||||
return len1 - len(self._cache)
|
||||
|
||||
163
konabot/plugins/fx_process/__init__.py
Normal file
@ -0,0 +1,163 @@
|
||||
import asyncio as asynkio
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
from inspect import signature
|
||||
|
||||
from konabot.common.nb.extract_image import DepImageBytes, DepPILImage
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot import on_message, logger
|
||||
|
||||
from nonebot_plugin_alconna import (
|
||||
UniMessage,
|
||||
UniMsg
|
||||
)
|
||||
|
||||
from konabot.plugins.fx_process.fx_manager import ImageFilterManager
|
||||
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
@dataclass
|
||||
class FilterItem:
|
||||
name: str
|
||||
filter: callable
|
||||
args: list
|
||||
|
||||
def prase_input_args(input_str: str) -> list[FilterItem]:
|
||||
# 按分号或换行符分割参数
|
||||
args = []
|
||||
for part in input_str.replace('\n', ';').split(';'):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
split_part = part.split()
|
||||
filter_name = split_part[0]
|
||||
if not ImageFilterManager.has_filter(filter_name):
|
||||
continue
|
||||
filter_func = ImageFilterManager.get_filter(filter_name)
|
||||
input_filter_args = split_part[1:]
|
||||
# 获取函数最大参数数量
|
||||
sig = signature(filter_func)
|
||||
max_params = len(sig.parameters) - 1 # 减去第一个参数 image
|
||||
# 从 args 提取参数,并转换为适当类型
|
||||
func_args = []
|
||||
for i in range(0, min(len(input_filter_args), max_params)):
|
||||
# 尝试将参数转换为函数签名中对应的类型
|
||||
param = list(sig.parameters.values())[i + 1]
|
||||
param_type = param.annotation
|
||||
arg_value = input_filter_args[i]
|
||||
try:
|
||||
if param_type is float:
|
||||
converted_value = float(arg_value)
|
||||
elif param_type is int:
|
||||
converted_value = int(arg_value)
|
||||
else:
|
||||
converted_value = arg_value
|
||||
except Exception:
|
||||
converted_value = arg_value
|
||||
func_args.append(converted_value)
|
||||
args.append(FilterItem(name=filter_name,filter=filter_func, args=func_args))
|
||||
return args
|
||||
|
||||
def apply_filters_to_image(img: Image, filters: list[FilterItem]) -> Image:
|
||||
for filter_item in filters:
|
||||
filter_func = filter_item.filter
|
||||
func_args = filter_item.args
|
||||
img = filter_func(img, *func_args)
|
||||
return img
|
||||
|
||||
async def apply_filters_to_bytes(image_bytes: bytes, filters: list[FilterItem]) -> BytesIO:
|
||||
# 检测是否需要将静态图视作动图处理
|
||||
frozen_to_move = any(
|
||||
filter_item.name == "动图"
|
||||
for filter_item in filters
|
||||
)
|
||||
static_fps = 10
|
||||
# 找到动图参数 fps
|
||||
if frozen_to_move:
|
||||
for filter_item in filters:
|
||||
if filter_item.name == "动图" and filter_item.args:
|
||||
try:
|
||||
static_fps = int(filter_item.args[0])
|
||||
except Exception:
|
||||
static_fps = 10
|
||||
break
|
||||
# 如果 image 是动图,则逐帧处理
|
||||
img = Image.open(BytesIO(image_bytes))
|
||||
logger.debug("开始图像处理")
|
||||
output = BytesIO()
|
||||
if getattr(img, "is_animated", False) or frozen_to_move:
|
||||
frames = []
|
||||
all_frames = []
|
||||
if getattr(img, "is_animated", False):
|
||||
logger.debug("处理动图帧")
|
||||
for frame in ImageSequence.Iterator(img):
|
||||
frame_copy = frame.copy()
|
||||
all_frames.append(frame_copy)
|
||||
else:
|
||||
# 将静态图视作单帧动图处理,拷贝多份
|
||||
logger.debug("处理静态图为多帧动图")
|
||||
for _ in range(10): # 默认复制10帧
|
||||
all_frames.append(img.copy())
|
||||
img.info['duration'] = int(1000 / static_fps)
|
||||
|
||||
async def process_single_frame(frame: Image.Image, frame_idx: int) -> Image.Image:
|
||||
"""处理单帧的异步函数"""
|
||||
logger.debug(f"开始处理帧 {frame_idx}")
|
||||
result = await asynkio.to_thread(apply_filters_to_image, frame, filters)
|
||||
logger.debug(f"完成处理帧 {frame_idx}")
|
||||
return result
|
||||
|
||||
# 并发处理所有帧
|
||||
tasks = []
|
||||
for i, frame in enumerate(all_frames):
|
||||
task = process_single_frame(frame, i)
|
||||
tasks.append(task)
|
||||
|
||||
frames = await asynkio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 检查是否有处理失败的帧
|
||||
for i, result in enumerate(frames):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"帧 {i} 处理失败: {result}")
|
||||
# 使用原始帧作为回退
|
||||
frames[i] = all_frames[i]
|
||||
|
||||
logger.debug("保存动图")
|
||||
frames[0].save(
|
||||
output,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
loop=img.info.get("loop", 0),
|
||||
disposal=img.info.get("disposal", 2),
|
||||
duration=img.info.get("duration", 100),
|
||||
)
|
||||
logger.debug("Animated image saved")
|
||||
else:
|
||||
img = apply_filters_to_image(img, filters)
|
||||
img.save(output, format="PNG")
|
||||
logger.debug("Image processing completed")
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
def is_fx_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
|
||||
txt = msg.extract_plain_text()
|
||||
if "fx" not in txt[:3].lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
fx_on = on_message(rule=is_fx_mentioned)
|
||||
|
||||
@fx_on.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, img: DepImageBytes):
|
||||
args = msg.extract_plain_text().split()
|
||||
if len(args) < 2:
|
||||
return
|
||||
filters = prase_input_args(msg.extract_plain_text()[2:])
|
||||
if not filters:
|
||||
return
|
||||
output = await apply_filters_to_bytes(img, filters)
|
||||
logger.debug("FX processing completed, sending result.")
|
||||
await fx_on.send(await UniMessage().image(raw=output).export())
|
||||
|
||||
50
konabot/plugins/fx_process/color_handle.py
Normal file
@ -0,0 +1,50 @@
|
||||
from typing import Optional
|
||||
from PIL import ImageColor
|
||||
|
||||
class ColorHandle:
|
||||
color_name_map = {
|
||||
"红": (255, 0, 0),
|
||||
"绿": (0, 255, 0),
|
||||
"蓝": (0, 0, 255),
|
||||
"黄": (255, 255, 0),
|
||||
"紫": (128, 0, 128),
|
||||
"黑": (0, 0, 0),
|
||||
"白": (255, 255, 255),
|
||||
"橙": (255, 165, 0),
|
||||
"粉": (255, 192, 203),
|
||||
"灰": (128, 128, 128),
|
||||
"青": (0, 255, 255),
|
||||
"靛": (75, 0, 130),
|
||||
"棕": (165, 42, 42),
|
||||
"浅": (200, 200, 200),
|
||||
"深": (50, 50, 50),
|
||||
"亮": (255, 255, 224),
|
||||
"暗": (47, 79, 79),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def set_or_blend_color(ori_color: Optional[tuple], target_color: tuple) -> tuple:
|
||||
# 如果没有指定初始颜色,返回目标颜色
|
||||
if ori_color is None:
|
||||
return target_color
|
||||
# 混合颜色,取平均值
|
||||
blended_color = tuple((o + t) // 2 for o, t in zip(ori_color, target_color))
|
||||
return blended_color
|
||||
|
||||
@staticmethod
|
||||
def parse_color(color_str: str) -> tuple:
|
||||
# 如果是纯括号,则加上前缀 rgb
|
||||
if color_str.startswith('(') and color_str.endswith(')'):
|
||||
color_str = 'rgb' + color_str
|
||||
try:
|
||||
return ImageColor.getrgb(color_str)
|
||||
except ValueError:
|
||||
pass
|
||||
base_color = None
|
||||
color_str = color_str.replace('色', '')
|
||||
for name, rgb in ColorHandle.color_name_map.items():
|
||||
if name in color_str:
|
||||
base_color = ColorHandle.set_or_blend_color(base_color, rgb)
|
||||
if base_color is not None:
|
||||
return base_color
|
||||
return (255, 255, 255) # 默认白色
|
||||
1087
konabot/plugins/fx_process/fx_handle.py
Normal file
60
konabot/plugins/fx_process/fx_manager.py
Normal file
@ -0,0 +1,60 @@
|
||||
from typing import Optional
|
||||
from konabot.plugins.fx_process.fx_handle import ImageFilterEmpty, ImageFilterImplement
|
||||
|
||||
class ImageFilterManager:
|
||||
filter_map = {
|
||||
"模糊": ImageFilterImplement.apply_blur,
|
||||
"马赛克": ImageFilterImplement.apply_mosaic,
|
||||
"轮廓": ImageFilterImplement.apply_contour,
|
||||
"锐化": ImageFilterImplement.apply_sharpen,
|
||||
"边缘增强": ImageFilterImplement.apply_edge_enhance,
|
||||
"浮雕": ImageFilterImplement.apply_emboss,
|
||||
"查找边缘": ImageFilterImplement.apply_find_edges,
|
||||
"平滑": ImageFilterImplement.apply_smooth,
|
||||
"反色": ImageFilterImplement.apply_invert,
|
||||
"黑白": ImageFilterImplement.apply_black_white,
|
||||
"阈值": ImageFilterImplement.apply_threshold,
|
||||
"对比度": ImageFilterImplement.apply_contrast,
|
||||
"亮度": ImageFilterImplement.apply_brightness,
|
||||
"色彩": ImageFilterImplement.apply_color,
|
||||
"色调": ImageFilterImplement.apply_to_color,
|
||||
"缩放": ImageFilterImplement.apply_resize,
|
||||
"波纹": ImageFilterImplement.apply_wave,
|
||||
"色键": ImageFilterImplement.apply_color_key,
|
||||
"暗角": ImageFilterImplement.apply_vignette,
|
||||
"发光": ImageFilterImplement.apply_glow,
|
||||
"RGB分离": ImageFilterImplement.apply_rgb_split,
|
||||
"光学补偿": ImageFilterImplement.apply_optical_compensation,
|
||||
"球面化": ImageFilterImplement.apply_spherize,
|
||||
"旋转": ImageFilterImplement.apply_rotate,
|
||||
"透视变换": ImageFilterImplement.apply_perspective_transform,
|
||||
"裁剪": ImageFilterImplement.apply_crop,
|
||||
"噪点": ImageFilterImplement.apply_noise,
|
||||
"平移": ImageFilterImplement.apply_translate,
|
||||
"拓展边缘": ImageFilterImplement.apply_expand_edges,
|
||||
"素描": ImageFilterImplement.apply_sketch,
|
||||
"叠加颜色": ImageFilterImplement.apply_gradient_overlay,
|
||||
"阴影": ImageFilterImplement.apply_shadow,
|
||||
"径向模糊": ImageFilterImplement.apply_radial_blur,
|
||||
"旋转模糊": ImageFilterImplement.apply_spin_blur,
|
||||
"方向模糊": ImageFilterImplement.apply_directional_blur,
|
||||
"边缘模糊": ImageFilterImplement.apply_focus_blur,
|
||||
"缩放模糊": ImageFilterImplement.apply_zoom_blur,
|
||||
"镜像": ImageFilterImplement.apply_mirror_half,
|
||||
"水平翻转": ImageFilterImplement.apply_flip_horizontal,
|
||||
"垂直翻转": ImageFilterImplement.apply_flip_vertical,
|
||||
"复制": ImageFilterImplement.copy_area,
|
||||
"晃动": ImageFilterImplement.apply_random_wiggle,
|
||||
"动图": ImageFilterEmpty.empty_filter_param,
|
||||
"像素抖动": ImageFilterImplement.apply_pixel_jitter,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_filter(cls, name: str) -> Optional[callable]:
|
||||
return cls.filter_map.get(name)
|
||||
|
||||
@classmethod
|
||||
def has_filter(cls, name: str) -> bool:
|
||||
return name in cls.filter_map
|
||||
|
||||
|
||||
341
konabot/plugins/fx_process/gradient.py
Normal file
@ -0,0 +1,341 @@
|
||||
import re
|
||||
from konabot.plugins.fx_process.color_handle import ColorHandle
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
class GradientGenerator:
|
||||
"""渐变生成器类"""
|
||||
|
||||
def __init__(self):
|
||||
self.has_numpy = hasattr(np, '__version__')
|
||||
|
||||
def parse_color_list(self, color_list_str: str) -> List[Dict]:
|
||||
"""解析渐变颜色列表字符串
|
||||
|
||||
Args:
|
||||
color_list_str: 格式如 '[rgb(255,0,0)|(0,0),rgb(0,255,0)|(0,100),rgb(0,0,255)|(50,100)]'
|
||||
|
||||
Returns:
|
||||
list: 包含颜色和位置信息的字典列表
|
||||
"""
|
||||
color_nodes = []
|
||||
color_list_str = color_list_str.strip('[] ').strip()
|
||||
pattern = r'([^|]+)\|\(([^)]+)\)'
|
||||
matches = re.findall(pattern, color_list_str)
|
||||
|
||||
for color_str, pos_str in matches:
|
||||
color = ColorHandle.parse_color(color_str.strip())
|
||||
|
||||
try:
|
||||
x_str, y_str = pos_str.split(',')
|
||||
x_percent = float(x_str.strip().replace('%', ''))
|
||||
y_percent = float(y_str.strip().replace('%', ''))
|
||||
x_percent = max(0, min(100, x_percent))
|
||||
y_percent = max(0, min(100, y_percent))
|
||||
except:
|
||||
x_percent = 0
|
||||
y_percent = 0
|
||||
|
||||
color_nodes.append({
|
||||
'color': color,
|
||||
'position': (x_percent / 100.0, y_percent / 100.0)
|
||||
})
|
||||
|
||||
if not color_nodes:
|
||||
color_nodes = [
|
||||
{'color': (255, 0, 0), 'position': (0, 0)},
|
||||
{'color': (0, 0, 255), 'position': (1, 1)}
|
||||
]
|
||||
|
||||
return color_nodes
|
||||
|
||||
def create_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
|
||||
"""创建渐变图像
|
||||
|
||||
Args:
|
||||
width: 图像宽度
|
||||
height: 图像高度
|
||||
color_nodes: 颜色节点列表
|
||||
|
||||
Returns:
|
||||
Image.Image: 渐变图像
|
||||
"""
|
||||
if len(color_nodes) == 1:
|
||||
return Image.new('RGB', (width, height), color_nodes[0]['color'])
|
||||
elif len(color_nodes) == 2:
|
||||
return self._create_linear_gradient(width, height, color_nodes)
|
||||
else:
|
||||
return self._create_radial_gradient(width, height, color_nodes)
|
||||
|
||||
def _create_linear_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
|
||||
"""创建线性渐变"""
|
||||
color1 = color_nodes[0]['color']
|
||||
color2 = color_nodes[1]['color']
|
||||
pos1 = color_nodes[0]['position']
|
||||
pos2 = color_nodes[1]['position']
|
||||
|
||||
if self.has_numpy:
|
||||
return self._create_linear_gradient_numpy(width, height, color1, color2, pos1, pos2)
|
||||
else:
|
||||
return self._create_linear_gradient_pil(width, height, color1, color2, pos1, pos2)
|
||||
|
||||
def _create_linear_gradient_numpy(self, width: int, height: int,
|
||||
color1: Tuple, color2: Tuple,
|
||||
pos1: Tuple, pos2: Tuple) -> Image.Image:
|
||||
"""使用numpy创建线性渐变"""
|
||||
# 创建坐标网格
|
||||
x = np.linspace(0, 1, width)
|
||||
y = np.linspace(0, 1, height)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
|
||||
# 计算渐变方向
|
||||
dx = pos2[0] - pos1[0]
|
||||
dy = pos2[1] - pos1[1]
|
||||
length_sq = dx * dx + dy * dy
|
||||
|
||||
if length_sq > 0:
|
||||
# 计算投影参数
|
||||
t = ((xx - pos1[0]) * dx + (yy - pos1[1]) * dy) / length_sq
|
||||
t = np.clip(t, 0, 1)
|
||||
else:
|
||||
t = np.zeros_like(xx)
|
||||
|
||||
# 插值颜色
|
||||
r = color1[0] + (color2[0] - color1[0]) * t
|
||||
g = color1[1] + (color2[1] - color1[1]) * t
|
||||
b = color1[2] + (color2[2] - color1[2]) * t
|
||||
|
||||
# 创建图像
|
||||
gradient_array = np.stack([r, g, b], axis=-1).astype(np.uint8)
|
||||
return Image.fromarray(gradient_array)
|
||||
|
||||
def _create_linear_gradient_pil(self, width: int, height: int,
|
||||
color1: Tuple, color2: Tuple,
|
||||
pos1: Tuple, pos2: Tuple) -> Image.Image:
|
||||
"""使用PIL创建线性渐变(没有numpy时使用)"""
|
||||
gradient = Image.new('RGB', (width, height))
|
||||
draw = ImageDraw.Draw(gradient)
|
||||
|
||||
# 判断渐变方向
|
||||
if abs(pos1[0] - pos2[0]) < 0.01: # 垂直渐变
|
||||
y1 = int(pos1[1] * (height - 1))
|
||||
y2 = int(pos2[1] * (height - 1))
|
||||
|
||||
if y2 < y1:
|
||||
y1, y2 = y2, y1
|
||||
color1, color2 = color2, color1
|
||||
|
||||
if y2 > y1:
|
||||
for y in range(height):
|
||||
if y <= y1:
|
||||
fill_color = color1
|
||||
elif y >= y2:
|
||||
fill_color = color2
|
||||
else:
|
||||
ratio = (y - y1) / (y2 - y1)
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||
fill_color = (r, g, b)
|
||||
|
||||
draw.line([(0, y), (width, y)], fill=fill_color)
|
||||
else:
|
||||
draw.rectangle([0, 0, width, height], fill=color1)
|
||||
|
||||
elif abs(pos1[1] - pos2[1]) < 0.01: # 水平渐变
|
||||
x1 = int(pos1[0] * (width - 1))
|
||||
x2 = int(pos2[0] * (width - 1))
|
||||
|
||||
if x2 < x1:
|
||||
x1, x2 = x2, x1
|
||||
color1, color2 = color2, color1
|
||||
|
||||
if x2 > x1:
|
||||
for x in range(width):
|
||||
if x <= x1:
|
||||
fill_color = color1
|
||||
elif x >= x2:
|
||||
fill_color = color2
|
||||
else:
|
||||
ratio = (x - x1) / (x2 - x1)
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||
fill_color = (r, g, b)
|
||||
|
||||
draw.line([(x, 0), (x, height)], fill=fill_color)
|
||||
else:
|
||||
draw.rectangle([0, 0, width, height], fill=color1)
|
||||
|
||||
else: # 对角渐变(简化处理为左上到右下)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
distance = (x/width + y/height) / 2
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * distance)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * distance)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * distance)
|
||||
draw.point((x, y), fill=(r, g, b))
|
||||
|
||||
return gradient
|
||||
|
||||
def _create_radial_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
|
||||
"""创建径向渐变"""
|
||||
if self.has_numpy and len(color_nodes) > 2:
|
||||
return self._create_radial_gradient_numpy(width, height, color_nodes)
|
||||
else:
|
||||
return self._create_simple_gradient(width, height, color_nodes)
|
||||
|
||||
def _create_radial_gradient_numpy(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
|
||||
"""使用numpy创建径向渐变(多色)"""
|
||||
# 创建坐标网格
|
||||
x = np.linspace(0, 1, width)
|
||||
y = np.linspace(0, 1, height)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
|
||||
# 提取颜色和位置
|
||||
positions = np.array([node['position'] for node in color_nodes])
|
||||
colors = np.array([node['color'] for node in color_nodes])
|
||||
|
||||
# 计算每个点到所有节点的距离
|
||||
distances = np.sqrt((xx[:, :, np.newaxis] - positions[np.newaxis, np.newaxis, :, 0]) ** 2 +
|
||||
(yy[:, :, np.newaxis] - positions[np.newaxis, np.newaxis, :, 1]) ** 2)
|
||||
|
||||
# 找到最近的两个节点
|
||||
sorted_indices = np.argsort(distances, axis=2)
|
||||
nearest_idx = sorted_indices[:, :, 0]
|
||||
second_idx = sorted_indices[:, :, 1]
|
||||
|
||||
# 获取对应的颜色
|
||||
nearest_colors = colors[nearest_idx]
|
||||
second_colors = colors[second_idx]
|
||||
|
||||
# 获取距离并计算权重
|
||||
nearest_dist = np.take_along_axis(distances, np.expand_dims(nearest_idx, axis=2), axis=2)[:, :, 0]
|
||||
second_dist = np.take_along_axis(distances, np.expand_dims(second_idx, axis=2), axis=2)[:, :, 0]
|
||||
|
||||
total_dist = nearest_dist + second_dist
|
||||
mask = total_dist > 0
|
||||
weight1 = np.zeros_like(nearest_dist)
|
||||
weight1[mask] = second_dist[mask] / total_dist[mask]
|
||||
weight2 = 1 - weight1
|
||||
|
||||
# 插值颜色
|
||||
r = nearest_colors[:, :, 0] * weight1 + second_colors[:, :, 0] * weight2
|
||||
g = nearest_colors[:, :, 1] * weight1 + second_colors[:, :, 1] * weight2
|
||||
b = nearest_colors[:, :, 2] * weight1 + second_colors[:, :, 2] * weight2
|
||||
|
||||
gradient_array = np.stack([r, g, b], axis=-1).astype(np.uint8)
|
||||
return Image.fromarray(gradient_array)
|
||||
|
||||
def _create_simple_gradient(self, width: int, height: int, color_nodes: List[Dict]) -> Image.Image:
|
||||
"""创建简化渐变(没有numpy或多色时使用)"""
|
||||
gradient = Image.new('RGB', (width, height))
|
||||
draw = ImageDraw.Draw(gradient)
|
||||
|
||||
if len(color_nodes) >= 2:
|
||||
# 使用第一个和最后一个颜色创建简单渐变
|
||||
color1 = color_nodes[0]['color']
|
||||
color2 = color_nodes[-1]['color']
|
||||
|
||||
# 判断节点分布
|
||||
x_positions = [node['position'][0] for node in color_nodes]
|
||||
y_positions = [node['position'][1] for node in color_nodes]
|
||||
|
||||
if all(abs(x - x_positions[0]) < 0.01 for x in x_positions):
|
||||
# 垂直渐变
|
||||
for y in range(height):
|
||||
ratio = y / (height - 1) if height > 1 else 0
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||
draw.line([(0, y), (width, y)], fill=(r, g, b))
|
||||
else:
|
||||
# 水平渐变
|
||||
for x in range(width):
|
||||
ratio = x / (width - 1) if width > 1 else 0
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||
draw.line([(x, 0), (x, height)], fill=(r, g, b))
|
||||
else:
|
||||
# 单色
|
||||
draw.rectangle([0, 0, width, height], fill=color_nodes[0]['color'])
|
||||
|
||||
return gradient
|
||||
|
||||
def create_simple_gradient(self, width: int, height: int,
|
||||
start_color: Tuple, end_color: Tuple,
|
||||
direction: str = 'vertical') -> Image.Image:
|
||||
"""创建简单双色渐变
|
||||
|
||||
Args:
|
||||
width: 图像宽度
|
||||
height: 图像高度
|
||||
start_color: 起始颜色
|
||||
end_color: 结束颜色
|
||||
direction: 渐变方向 'vertical', 'horizontal', 'diagonal'
|
||||
|
||||
Returns:
|
||||
Image.Image: 渐变图像
|
||||
"""
|
||||
if direction == 'vertical':
|
||||
return self._create_vertical_gradient(width, height, start_color, end_color)
|
||||
elif direction == 'horizontal':
|
||||
return self._create_horizontal_gradient(width, height, start_color, end_color)
|
||||
else: # diagonal
|
||||
return self._create_diagonal_gradient(width, height, start_color, end_color)
|
||||
|
||||
def _create_vertical_gradient(self, width: int, height: int,
|
||||
color1: Tuple, color2: Tuple) -> Image.Image:
|
||||
"""创建垂直渐变"""
|
||||
gradient = Image.new('RGB', (width, height))
|
||||
draw = ImageDraw.Draw(gradient)
|
||||
|
||||
for y in range(height):
|
||||
ratio = y / (height - 1) if height > 1 else 0
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||
draw.line([(0, y), (width, y)], fill=(r, g, b))
|
||||
|
||||
return gradient
|
||||
|
||||
def _create_horizontal_gradient(self, width: int, height: int,
|
||||
color1: Tuple, color2: Tuple) -> Image.Image:
|
||||
"""创建水平渐变"""
|
||||
gradient = Image.new('RGB', (width, height))
|
||||
draw = ImageDraw.Draw(gradient)
|
||||
|
||||
for x in range(width):
|
||||
ratio = x / (width - 1) if width > 1 else 0
|
||||
r = int(color1[0] + (color2[0] - color1[0]) * ratio)
|
||||
g = int(color1[1] + (color2[1] - color1[1]) * ratio)
|
||||
b = int(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||
draw.line([(x, 0), (x, height)], fill=(r, g, b))
|
||||
|
||||
return gradient
|
||||
|
||||
def _create_diagonal_gradient(self, width: int, height: int,
|
||||
color1: Tuple, color2: Tuple) -> Image.Image:
|
||||
"""创建对角渐变"""
|
||||
if self.has_numpy:
|
||||
return self._create_diagonal_gradient_numpy(width, height, color1, color2)
|
||||
else:
|
||||
return self._create_horizontal_gradient(width, height, color1, color2) # 降级为水平渐变
|
||||
|
||||
def _create_diagonal_gradient_numpy(self, width: int, height: int,
|
||||
color1: Tuple, color2: Tuple) -> Image.Image:
|
||||
"""使用numpy创建对角渐变"""
|
||||
x = np.linspace(0, 1, width)
|
||||
y = np.linspace(0, 1, height)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
|
||||
distance = (xx + yy) / 2.0
|
||||
|
||||
r = color1[0] + (color2[0] - color1[0]) * distance
|
||||
g = color1[1] + (color2[1] - color1[1]) * distance
|
||||
b = color1[2] + (color2[2] - color1[2]) * distance
|
||||
|
||||
gradient_array = np.stack([r, g, b], axis=-1).astype(np.uint8)
|
||||
return Image.fromarray(gradient_array)
|
||||
217
konabot/plugins/hanzi/__init__.py
Normal file
@ -0,0 +1,217 @@
|
||||
import random
|
||||
from typing import Optional
|
||||
import opencc
|
||||
|
||||
from nonebot import on_message
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import (
|
||||
Alconna,
|
||||
Args,
|
||||
UniMessage,
|
||||
UniMsg,
|
||||
on_alconna,
|
||||
)
|
||||
|
||||
convert_type = ["简","簡","繁","正","港","日"]
|
||||
|
||||
compiled_str = "|".join([f"{a}{mid}{b}" for mid in ["转","轉","転"] for a in convert_type for b in convert_type if a != b])
|
||||
|
||||
def hanzi_to_abbr(hanzi: str) -> str:
|
||||
mapping = {
|
||||
"简": "s",
|
||||
"簡": "s",
|
||||
"繁": "t",
|
||||
"正": "t",
|
||||
"港": "hk",
|
||||
"日": "jp",
|
||||
}
|
||||
return mapping.get(hanzi, "")
|
||||
|
||||
def check_valid_convert_type(convert_type: str) -> bool:
|
||||
avaliable_set = ["s2t","t2s","s2tw","tw2s","s2hk","hk2s","s2twp","tw2sp","t2tw","hk2t","t2hk","t2jp","jp2t","tw2t"]
|
||||
if convert_type in avaliable_set:
|
||||
return True
|
||||
return False
|
||||
|
||||
def convert(source, src_abbr, dst_abbr):
|
||||
convert_type_key = f"{src_abbr}2{dst_abbr}"
|
||||
if not check_valid_convert_type(convert_type_key):
|
||||
# 先转为繁体,再转为目标
|
||||
converter = opencc.OpenCC(f"{src_abbr}2t.json")
|
||||
source = converter.convert(source)
|
||||
src_abbr = "t"
|
||||
converter = opencc.OpenCC(f"{src_abbr}2{dst_abbr}.json")
|
||||
converted = converter.convert(source)
|
||||
return converted
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
f"re:({compiled_str})",
|
||||
Args["source?", str],
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = event.get_message().extract_plain_text()
|
||||
else:
|
||||
content = event.get_message().extract_plain_text()
|
||||
|
||||
prefix = content.split()[0]
|
||||
to_convert = ""
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(source is None):
|
||||
if event.reply:
|
||||
to_convert = event.reply.message.extract_plain_text()
|
||||
if not to_convert:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
to_convert = source
|
||||
parts = []
|
||||
if "转" in prefix:
|
||||
parts = prefix.split("转")
|
||||
elif "轉" in prefix:
|
||||
parts = prefix.split("轉")
|
||||
elif "転" in prefix:
|
||||
parts = prefix.split("転")
|
||||
if len(parts) != 2:
|
||||
notice = "转换格式错误,请使用“简转繁”、“繁转简”等格式。"
|
||||
await evt.send(await UniMessage().text(notice).export())
|
||||
return
|
||||
src, dst = parts
|
||||
src_abbr = hanzi_to_abbr(src)
|
||||
dst_abbr = hanzi_to_abbr(dst)
|
||||
if not src_abbr or not dst_abbr:
|
||||
notice = "不支持的转换类型,请使用“简”、“繁”、“正”、“港”、“日”等。"
|
||||
if src_abbr:
|
||||
notice = convert(notice, "s", src_abbr)
|
||||
await evt.send(await UniMessage().text(notice).export())
|
||||
return
|
||||
|
||||
converted = convert(to_convert, src_abbr, dst_abbr)
|
||||
|
||||
converted_prefix = convert("转换结果", "s", dst_abbr)
|
||||
|
||||
await evt.send(await UniMessage().text(f"{converted_prefix}:{converted}").export())
|
||||
|
||||
shuo = ["说","說"]
|
||||
|
||||
full_name_type = ["简体","簡體","繁體","繁体","正體","正体","港話","港话","日文"]
|
||||
|
||||
combined_list = [f"{a}{b}" for a in shuo for b in full_name_type]
|
||||
|
||||
compiled_str_2 = "|".join(combined_list)
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
f"re:({compiled_str_2})",
|
||||
Args["source?", str]
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = event.get_message().extract_plain_text()
|
||||
else:
|
||||
content = event.get_message().extract_plain_text()
|
||||
|
||||
prefix = content.split()[0]
|
||||
to_convert = ""
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(source is None):
|
||||
if event.reply:
|
||||
to_convert = event.reply.message.extract_plain_text()
|
||||
if not to_convert:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
to_convert = source
|
||||
# 获取目标转换类型
|
||||
dst = ""
|
||||
match prefix:
|
||||
case "说简体" | "說簡體" | "说簡體" | "說简体":
|
||||
dst = "简"
|
||||
case "說繁體" | "说繁体" | "說繁体" | "说繁體":
|
||||
dst = "繁"
|
||||
case "說正體" | "说正体" | "說正体" | "说正體":
|
||||
dst = "正"
|
||||
case "說港話" | "说港话" | "說港话" | "说港話":
|
||||
dst = "港"
|
||||
case "說日文" | "说日文":
|
||||
dst = "日"
|
||||
dst_abbr = hanzi_to_abbr(dst)
|
||||
if not dst_abbr:
|
||||
notice = "不支持的转换类型,请使用“简体”、“繁體”、“正體”、“港話”、“日文”等。"
|
||||
await evt.send(await UniMessage().text(notice).export())
|
||||
return
|
||||
# 循环,将源语言一次次转换为目标语言
|
||||
current_text = to_convert
|
||||
for src_abbr in ["s","hk","jp","tw","t"]:
|
||||
if src_abbr != dst_abbr:
|
||||
current_text = convert(current_text, src_abbr, dst_abbr)
|
||||
|
||||
converted_prefix = convert("转换结果", "s", dst_abbr)
|
||||
|
||||
await evt.send(await UniMessage().text(f"{converted_prefix}:{current_text}").export())
|
||||
|
||||
def random_char(char: str) -> str:
|
||||
dst_abbr = random.choice(["s","t","hk","jp","tw"])
|
||||
for src_abbr in ["s","hk","jp","tw","t"]:
|
||||
if src_abbr != dst_abbr:
|
||||
char = convert(char, src_abbr, dst_abbr)
|
||||
return char
|
||||
|
||||
def random_string(text: str) -> str:
|
||||
final_text = ""
|
||||
for char in text:
|
||||
final_text += random_char(char)
|
||||
return final_text
|
||||
|
||||
random_match = ["混乱字形","混亂字形","乱数字形","亂數字形","ランダム字形"]
|
||||
|
||||
evt = on_alconna(
|
||||
Alconna(
|
||||
f"re:({'|'.join(random_match)})",
|
||||
Args["source?", str]
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=True,
|
||||
)
|
||||
@evt.handle()
|
||||
async def _(msg: UniMsg, event: BaseEvent, source: Optional[str] = None):
|
||||
if isinstance(event, DiscordMessageEvent):
|
||||
content = event.get_message().extract_plain_text()
|
||||
else:
|
||||
content = event.get_message().extract_plain_text()
|
||||
|
||||
prefix = content.split()[0]
|
||||
to_convert = ""
|
||||
# 如果回复了消息,则转换回复的内容
|
||||
if(source is None):
|
||||
if event.reply:
|
||||
to_convert = event.reply.message.extract_plain_text()
|
||||
if not to_convert:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
to_convert = source
|
||||
|
||||
final_text = ""
|
||||
final_text = random_string(to_convert)
|
||||
converted_prefix = convert(random_string("转换结果"), "s", "s")
|
||||
|
||||
await evt.send(await UniMessage().text(f"{converted_prefix}:{final_text}").export())
|
||||
@ -8,6 +8,7 @@ from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from nonebot import on_message
|
||||
import nonebot
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import (
|
||||
@ -18,19 +19,28 @@ from nonebot_plugin_alconna import (
|
||||
on_alconna,
|
||||
)
|
||||
|
||||
from konabot.common.database import DatabaseManager
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
|
||||
from konabot.common.llm import get_llm
|
||||
|
||||
ROOT_PATH = Path(__file__).resolve().parent
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
|
||||
|
||||
DATA_FILE_PATH = (
|
||||
Path(__file__).parent.parent.parent.parent / "data" / "idiom_banned.json"
|
||||
DATA_DIR / "idiom_banned.json"
|
||||
)
|
||||
|
||||
# 创建全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
def load_banned_ids() -> list[str]:
|
||||
if not DATA_FILE_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(DATA_FILE_PATH.read_text())
|
||||
return json.loads(DATA_FILE_PATH.read_text("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(f"在解析成语接龙封禁文件时遇到问题:{e}")
|
||||
return []
|
||||
@ -45,14 +55,29 @@ def add_banned_id(group_id: str):
|
||||
banned_ids = load_banned_ids()
|
||||
if group_id not in banned_ids:
|
||||
banned_ids.append(group_id)
|
||||
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4))
|
||||
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4), "utf-8")
|
||||
|
||||
|
||||
def remove_banned_id(group_id: str):
|
||||
banned_ids = load_banned_ids()
|
||||
if group_id in banned_ids:
|
||||
banned_ids.remove(group_id)
|
||||
DATA_FILE_PATH.write_text(json.dumps(banned_ids, ensure_ascii=False, indent=4))
|
||||
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):
|
||||
@ -69,11 +94,38 @@ class TryStopState(Enum):
|
||||
class TryVerifyState(Enum):
|
||||
VERIFIED = 0
|
||||
VERIFIED_AND_REAL = 1
|
||||
NOT_IDIOM = 2
|
||||
WRONG_FIRST_CHAR = 3
|
||||
VERIFIED_BUT_NO_NEXT = 4
|
||||
VERIFIED_GAME_END = 5
|
||||
ALREADY_USED = 2
|
||||
NOT_IDIOM = 3
|
||||
WRONG_FIRST_CHAR = 4
|
||||
BUT_NO_NEXT = 5
|
||||
GAME_END = 6
|
||||
|
||||
class IdiomGameLLM:
|
||||
@classmethod
|
||||
async def verify_idiom_with_llm(cls, idiom: str) -> bool:
|
||||
if len(idiom) != 4:
|
||||
return False
|
||||
llm = get_llm()
|
||||
system_prompt = "请判断用户的输入是否为一个合理的成语,或者这四个字在中文环境下是否说得通。如果是请回答「T」,否则回答「F」。请注意,即使这个词不是成语,如果说得通(也就是能念起来很通顺),你也该输出「T」。请不要包含任何解释,也不要包含任何标点符号。"
|
||||
message = await llm.chat([{"role": "system", "content": system_prompt}, {"role": "user", "content": idiom}])
|
||||
answer = message.content
|
||||
logger.info(f"LLM 对成语 {idiom} 的判断结果是 {answer}")
|
||||
if answer == "T":
|
||||
await cls.storage_idiom(idiom)
|
||||
return answer == "T"
|
||||
|
||||
@classmethod
|
||||
async def storage_idiom(cls, idiom: str):
|
||||
# 将 idiom 存入数据库
|
||||
# await db_manager.execute_by_sql_file(
|
||||
# ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||
# (idiom,)
|
||||
# )
|
||||
# 将 idiom 存入本地文件以备后续分析
|
||||
with open(DATA_DIR / "idiom_llm_storage.txt", "a", encoding="utf-8") as f:
|
||||
f.write(idiom + "\n")
|
||||
IdiomGame.append_into_word_list(idiom)
|
||||
|
||||
|
||||
class IdiomGame:
|
||||
ALL_WORDS = [] # 所有四字词语
|
||||
@ -96,30 +148,57 @@ class IdiomGame:
|
||||
self.all_buff_score = 0
|
||||
self.lock = asynkio.Lock()
|
||||
self.remain_rounds = 0 # 剩余回合数
|
||||
self.already_idioms: dict[str, int] = {} # 已经使用过的成语和使用过的次数
|
||||
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
|
||||
IdiomGame.INSTANCE_LIST[group_id] = self
|
||||
|
||||
@classmethod
|
||||
async def append_into_word_list(cls, word: str):
|
||||
'''
|
||||
将一个新词加入到词语列表中
|
||||
'''
|
||||
if word not in cls.ALL_WORDS:
|
||||
cls.ALL_WORDS.append(word)
|
||||
if word[0] not in cls.IDIOM_FIRST_CHAR:
|
||||
cls.IDIOM_FIRST_CHAR[word[0]] = []
|
||||
cls.IDIOM_FIRST_CHAR[word[0]].append(word)
|
||||
# await db_manager.execute_by_sql_file(
|
||||
# ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||
# (word,)
|
||||
# )
|
||||
|
||||
def be_able_to_play(self) -> bool:
|
||||
if self.last_play_date != datetime.date.today():
|
||||
self.last_play_date = datetime.date.today()
|
||||
self.remain_playing_times = 1
|
||||
self.remain_playing_times = 3
|
||||
if self.remain_playing_times > 0:
|
||||
self.remain_playing_times -= 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def choose_start_idiom(self) -> str:
|
||||
@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"]
|
||||
return secrets.choice(IdiomGame.ALL_IDIOMS)
|
||||
|
||||
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]
|
||||
if not self.is_nextable(self.last_char):
|
||||
self.choose_start_idiom()
|
||||
if not await self.is_nextable(self.last_char):
|
||||
await self.choose_start_idiom()
|
||||
else:
|
||||
self.add_history_idiom(self.last_idiom, new_chain=True)
|
||||
return self.last_idiom
|
||||
|
||||
@classmethod
|
||||
def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
|
||||
cls.init_lexicon()
|
||||
async def try_start_game(cls, group_id: str, force: bool = False) -> TryStartState:
|
||||
await cls.init_lexicon()
|
||||
if not cls.INSTANCE_LIST.get(group_id):
|
||||
cls(group_id)
|
||||
instance = cls.INSTANCE_LIST[group_id]
|
||||
@ -130,10 +209,10 @@ class IdiomGame:
|
||||
instance.now_playing = True
|
||||
return TryStartState.STARTED
|
||||
|
||||
def start_game(self, rounds: int = 100):
|
||||
async def start_game(self, rounds: int = 100):
|
||||
self.now_playing = True
|
||||
self.remain_rounds = rounds
|
||||
self.choose_start_idiom()
|
||||
await self.choose_start_idiom()
|
||||
|
||||
@classmethod
|
||||
def try_stop_game(cls, group_id: str) -> TryStopState:
|
||||
@ -148,6 +227,9 @@ class IdiomGame:
|
||||
def clear_score_board(self):
|
||||
self.score_board = {}
|
||||
self.last_char = ""
|
||||
self.all_buff_score = 0
|
||||
self.already_idioms = {}
|
||||
self.idiom_history = []
|
||||
|
||||
def get_score_board(self) -> dict:
|
||||
return self.score_board
|
||||
@ -160,60 +242,116 @@ class IdiomGame:
|
||||
跳过当前成语,选择下一个成语
|
||||
"""
|
||||
async with self.lock:
|
||||
self._skip_idiom_async()
|
||||
await self._skip_idiom_async()
|
||||
self.add_buff_score(buff_score)
|
||||
return self.last_idiom
|
||||
|
||||
def _skip_idiom_async(self) -> str:
|
||||
self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
|
||||
async def _skip_idiom_async(self) -> str:
|
||||
self.last_idiom = await IdiomGame.random_idiom()
|
||||
self.last_char = self.last_idiom[-1]
|
||||
if not self.is_nextable(self.last_char):
|
||||
self._skip_idiom_async()
|
||||
if not await self.is_nextable(self.last_char):
|
||||
await self._skip_idiom_async()
|
||||
else:
|
||||
self.add_history_idiom(self.last_idiom, new_chain=True)
|
||||
return self.last_idiom
|
||||
|
||||
async def try_verify_idiom(self, idiom: str, user_id: str) -> TryVerifyState:
|
||||
async def try_verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
||||
"""
|
||||
用户发送成语
|
||||
"""
|
||||
async with self.lock:
|
||||
state = self._verify_idiom(idiom, user_id)
|
||||
state = await self._verify_idiom(idiom, user_id)
|
||||
return state
|
||||
|
||||
def is_nextable(self, last_char: str) -> bool:
|
||||
async def is_nextable(self, last_char: str) -> bool:
|
||||
"""
|
||||
判断是否有成语可以接
|
||||
"""
|
||||
# result = await db_manager.query_by_sql_file(
|
||||
# ROOT_PATH / "sql" / "is_nextable.sql",
|
||||
# (last_char,)
|
||||
# )
|
||||
# return result[0]["DEED"] == 1
|
||||
return last_char in IdiomGame.AVALIABLE_IDIOM_FIRST_CHAR
|
||||
|
||||
def add_already_idiom(self, idiom: str):
|
||||
if idiom in self.already_idioms:
|
||||
self.already_idioms[idiom] += 1
|
||||
else:
|
||||
self.already_idioms[idiom] = 1
|
||||
|
||||
def _verify_idiom(self, idiom: str, user_id: str) -> TryVerifyState:
|
||||
def get_already_used_num(self, idiom: str) -> int:
|
||||
if idiom in self.already_idioms:
|
||||
return self.already_idioms[idiom]
|
||||
return 0
|
||||
|
||||
def add_history_idiom(self, idiom: str, new_chain: bool = False):
|
||||
if new_chain or len(self.idiom_history) == 0:
|
||||
self.idiom_history.append([idiom])
|
||||
else:
|
||||
self.idiom_history[-1].append(idiom)
|
||||
|
||||
def display_history(self) -> list[str]:
|
||||
result = []
|
||||
for chain in self.idiom_history:
|
||||
result.append(" -> ".join(chain))
|
||||
return result
|
||||
|
||||
async def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
||||
state = []
|
||||
# 新成语的首字应与上一条成语的尾字相同
|
||||
if idiom[0] != self.last_char:
|
||||
return TryVerifyState.WRONG_FIRST_CHAR
|
||||
state.append(TryVerifyState.WRONG_FIRST_CHAR)
|
||||
return state
|
||||
# 成语是否存在
|
||||
# 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:
|
||||
if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
|
||||
self.add_score(user_id, -0.1)
|
||||
return TryVerifyState.NOT_IDIOM
|
||||
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
|
||||
try:
|
||||
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
|
||||
self.add_score(user_id, -0.1)
|
||||
state.append(TryVerifyState.NOT_IDIOM)
|
||||
return state
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 验证成语 {idiom} 时出现错误:{e}")
|
||||
self.add_score(user_id, -0.1)
|
||||
state.append(TryVerifyState.NOT_IDIOM)
|
||||
return state
|
||||
# 成语合法,更新状态
|
||||
self.add_history_idiom(idiom)
|
||||
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
|
||||
if(score_k != 1):
|
||||
state.append(TryVerifyState.ALREADY_USED)
|
||||
self.add_already_idiom(idiom)
|
||||
state.append(TryVerifyState.VERIFIED)
|
||||
self.last_idiom = idiom
|
||||
self.last_char = idiom[-1]
|
||||
self.add_score(user_id, 1)
|
||||
self.add_score(user_id, 1 * score_k) # 先加 1 分
|
||||
# if status_result == 1:
|
||||
if idiom in IdiomGame.ALL_IDIOMS:
|
||||
self.add_score(user_id, 4) # 再加 4 分
|
||||
state.append(TryVerifyState.VERIFIED_AND_REAL)
|
||||
self.add_score(user_id, 4 * score_k) # 再加 4 分
|
||||
self.remain_rounds -= 1
|
||||
if self.remain_rounds <= 0:
|
||||
self.now_playing = False
|
||||
return TryVerifyState.VERIFIED_GAME_END
|
||||
if not self.is_nextable(self.last_char):
|
||||
state.append(TryVerifyState.GAME_END)
|
||||
if not await self.is_nextable(self.last_char):
|
||||
# 没有成语可以接了,自动跳过
|
||||
self._skip_idiom_async()
|
||||
return TryVerifyState.VERIFIED_BUT_NO_NEXT
|
||||
if idiom in IdiomGame.ALL_IDIOMS:
|
||||
return TryVerifyState.VERIFIED_AND_REAL # 真实成语
|
||||
return TryVerifyState.VERIFIED
|
||||
await self._skip_idiom_async()
|
||||
self.add_buff_score(-100)
|
||||
state.append(TryVerifyState.BUT_NO_NEXT)
|
||||
return state
|
||||
|
||||
def get_user_score(self, user_id: str) -> float:
|
||||
if user_id not in self.score_board:
|
||||
return 0
|
||||
# 避免浮点数精度问题导致过长
|
||||
handled_score = round(self.score_board[user_id]["score"], 1)
|
||||
handled_score = round(self.score_board[user_id]["score"] + self.all_buff_score, 1)
|
||||
return handled_score
|
||||
|
||||
def add_score(self, user_id: str, score: int):
|
||||
@ -231,16 +369,27 @@ class IdiomGame:
|
||||
return self.last_char
|
||||
|
||||
@classmethod
|
||||
def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
|
||||
cls.init_lexicon()
|
||||
if first_char not in cls.IDIOM_FIRST_CHAR:
|
||||
async def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
|
||||
# await cls.init_lexicon()
|
||||
# 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 result[0]["idiom"]
|
||||
await cls.init_lexicon()
|
||||
if first_char not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
|
||||
return None
|
||||
return secrets.choice(cls.IDIOM_FIRST_CHAR[first_char])
|
||||
return secrets.choice(cls.AVALIABLE_IDIOM_FIRST_CHAR[first_char])
|
||||
|
||||
@classmethod
|
||||
def init_lexicon(cls):
|
||||
async def init_lexicon(cls):
|
||||
if cls.__inited:
|
||||
return
|
||||
# await db_manager.execute_by_sql_file(
|
||||
# ROOT_PATH / "sql" / "create_table.sql"
|
||||
# ) # 确保数据库初始化
|
||||
cls.__inited = True
|
||||
|
||||
# 成语大表
|
||||
@ -248,8 +397,12 @@ class IdiomGame:
|
||||
ALL_IDIOMS_INFOS = json.load(f)
|
||||
|
||||
# 词语大表
|
||||
ALL_WORDS = []
|
||||
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
|
||||
cls.ALL_WORDS = json.load(f)
|
||||
jsonData = json.load(f)
|
||||
ALL_WORDS = [item["ci"] for item in jsonData]
|
||||
logger.debug(f"Loaded {len(ALL_WORDS)} words from ci.json")
|
||||
logger.debug(f"Sample words: {ALL_WORDS[:5]}")
|
||||
|
||||
COMMON_WORDS = []
|
||||
# 读取 COMMON 词语大表
|
||||
@ -258,6 +411,8 @@ class IdiomGame:
|
||||
word = line.strip()
|
||||
if len(word) == 4:
|
||||
COMMON_WORDS.append(word)
|
||||
logger.debug(f"Loaded {len(COMMON_WORDS)} common words from common.txt")
|
||||
logger.debug(f"Sample common words: {COMMON_WORDS[:5]}")
|
||||
|
||||
# 读取 THUOCL 成语库
|
||||
with open(
|
||||
@ -265,7 +420,9 @@ class IdiomGame:
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
THUOCL_IDIOMS = [line.split(" ")[0].strip() for line in f]
|
||||
THUOCL_IDIOMS = [line.split(" ")[0].split("\t")[0].strip() for line in f]
|
||||
logger.debug(f"Loaded {len(THUOCL_IDIOMS)} idioms from THUOCL_chengyu.txt")
|
||||
logger.debug(f"Sample idioms: {THUOCL_IDIOMS[:5]}")
|
||||
|
||||
# 读取 THUOCL 剩下的所有 txt 文件,只保留四字词
|
||||
THUOCL_WORDS = []
|
||||
@ -279,21 +436,53 @@ class IdiomGame:
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
for line in f:
|
||||
word = line.lstrip().split(" ")[0].strip()
|
||||
word = line.lstrip().split(" ")[0].split("\t")[0].strip()
|
||||
if len(word) == 4:
|
||||
THUOCL_WORDS.append(word)
|
||||
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
|
||||
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
|
||||
|
||||
# 读取本地的 idiom_llm_storage.txt 文件,补充词语表
|
||||
LOCAL_LLM_WORDS = []
|
||||
if (DATA_DIR / "idiom_llm_storage.txt").exists():
|
||||
with open(DATA_DIR / "idiom_llm_storage.txt", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
word = line.strip()
|
||||
if len(word) == 4:
|
||||
LOCAL_LLM_WORDS.append(word)
|
||||
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
|
||||
|
||||
# 只有成语的大表
|
||||
cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
|
||||
cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
|
||||
ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_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 = (
|
||||
[word for word in cls.ALL_WORDS if len(word) == 4]
|
||||
ALL_WORDS = (
|
||||
[word for word in ALL_WORDS if len(word) == 4]
|
||||
+ THUOCL_WORDS
|
||||
+ COMMON_WORDS
|
||||
)
|
||||
cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
|
||||
|
||||
cls.ALL_WORDS = ALL_WORDS + LOCAL_LLM_WORDS
|
||||
cls.ALL_IDIOMS = ALL_IDIOMS
|
||||
|
||||
# 插入数据库
|
||||
# await db_manager.execute_many_values_by_sql_file(
|
||||
# ROOT_PATH / "sql" / "insert_word.sql",
|
||||
# [(word,) for word in ALL_WORDS]
|
||||
# )
|
||||
|
||||
# 自定义词语 LOCAL_LLM_WORDS 插入数据库,兼容用
|
||||
# await db_manager.execute_many_values_by_sql_file(
|
||||
# ROOT_PATH / "sql" / "insert_custom_word.sql",
|
||||
# [(word,) for word in LOCAL_LLM_WORDS]
|
||||
# )
|
||||
|
||||
# 根据成语大表,划分出成语首字字典
|
||||
for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
|
||||
@ -337,7 +526,7 @@ async def play_game(
|
||||
if rounds <= 0:
|
||||
await evt.send(await UniMessage().text("干什么!你想玩负数局吗?").export())
|
||||
return
|
||||
state = IdiomGame.try_start_game(group_id, force)
|
||||
state = await IdiomGame.try_start_game(group_id, force)
|
||||
if state == TryStartState.ALREADY_PLAYING:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
@ -356,7 +545,7 @@ async def play_game(
|
||||
.export()
|
||||
)
|
||||
instance = IdiomGame.INSTANCE_LIST[group_id]
|
||||
instance.start_game(rounds)
|
||||
await instance.start_game(rounds)
|
||||
# 发布成语
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
@ -388,7 +577,7 @@ async def end_game(event: BaseEvent, group_id: str):
|
||||
result_text = UniMessage().text("游戏结束!\n最终得分榜:\n")
|
||||
score_board = instance.get_score_board()
|
||||
if len(score_board) == 0:
|
||||
result_text += "无人得分!"
|
||||
result_text += "无人得分!\n"
|
||||
else:
|
||||
# 按分数排序,名字用 at 的方式
|
||||
sorted_score = sorted(
|
||||
@ -400,8 +589,17 @@ async def end_game(event: BaseEvent, group_id: str):
|
||||
+ UniMessage().at(user_id)
|
||||
+ f": {round(info['score'] + instance.get_all_buff_score(), 1)} 分\n"
|
||||
)
|
||||
if len(instance.idiom_history) == 0:
|
||||
result_text += "\n本局没有任何接龙记录。"
|
||||
else:
|
||||
result_text += "\n你们的接龙记录是:\n"
|
||||
history_lines = instance.display_history()
|
||||
for line in history_lines:
|
||||
result_text += line + "\n"
|
||||
await evt.send(await result_text.export())
|
||||
instance.clear_score_board()
|
||||
# instance.clear_score_board()
|
||||
# 将实例删除
|
||||
del IdiomGame.INSTANCE_LIST[group_id]
|
||||
|
||||
|
||||
evt = on_alconna(
|
||||
@ -440,7 +638,11 @@ async def _(target: DepLongTaskTarget):
|
||||
instance = IdiomGame.INSTANCE_LIST.get(group_id)
|
||||
if not instance or not instance.get_playing_state():
|
||||
return
|
||||
avaliable_idiom = IdiomGame.random_idiom_starting_with(instance.get_last_char())
|
||||
avaliable_idiom = await IdiomGame.random_idiom_starting_with(instance.get_last_char())
|
||||
# 发送哈哈狗图片
|
||||
with open(ASSETS_PATH / "img" / "dog" / "haha_dog.jpg", "rb") as f:
|
||||
img_data = f.read()
|
||||
await evt.send(await UniMessage().image(raw=img_data).export())
|
||||
await evt.send(await UniMessage().text(f"你们太菜了,全部扣100分!明明还可以接「{avaliable_idiom}」的!").export())
|
||||
idiom = await instance.skip_idiom(-100)
|
||||
await evt.send(
|
||||
@ -472,9 +674,9 @@ async def _(event: BaseEvent, msg: UniMsg, target: DepLongTaskTarget):
|
||||
user_idiom = msg.extract_plain_text().strip()
|
||||
user_id, user_name = get_user_info(event)
|
||||
state = await instance.try_verify_idiom(user_idiom, user_id)
|
||||
if state == TryVerifyState.WRONG_FIRST_CHAR:
|
||||
if TryVerifyState.WRONG_FIRST_CHAR in state:
|
||||
return
|
||||
if state == TryVerifyState.NOT_IDIOM:
|
||||
if TryVerifyState.NOT_IDIOM in state:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
@ -482,25 +684,44 @@ async def _(event: BaseEvent, msg: UniMsg, target: DepLongTaskTarget):
|
||||
.export()
|
||||
)
|
||||
return
|
||||
if state == TryVerifyState.VERIFIED:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,喜提 1 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
elif state == TryVerifyState.VERIFIED_AND_REAL:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,这是个真实成语,喜提 5 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
if state == TryVerifyState.VERIFIED_GAME_END:
|
||||
already_used_num = instance.get_already_used_num(user_idiom)
|
||||
if TryVerifyState.VERIFIED_AND_REAL in state:
|
||||
score = 5 * (0.5 ** (already_used_num - 1))
|
||||
if already_used_num > 1:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,这是个被重复用过的成语,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
else:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,这是个真实成语,喜提 5 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
elif TryVerifyState.VERIFIED in state:
|
||||
score = 1 * (0.5 ** (already_used_num - 1))
|
||||
if already_used_num > 1:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,但重复了,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
else:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.at(user_id)
|
||||
.text(f" 接上了,喜提 1 分!你有 {instance.get_user_score(user_id)} 分!")
|
||||
.export()
|
||||
)
|
||||
if TryVerifyState.GAME_END in state:
|
||||
await evt.send(await UniMessage().text("全部回合结束!").export())
|
||||
await end_game(event, group_id)
|
||||
return
|
||||
if state == TryVerifyState.VERIFIED_BUT_NO_NEXT:
|
||||
if TryVerifyState.BUT_NO_NEXT in state:
|
||||
await evt.send(
|
||||
await UniMessage()
|
||||
.text("但是,这是条死路!你们全部都要扣 100 分!")
|
||||
|
||||
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
@ -0,0 +1,3 @@
|
||||
-- 插入自定义词
|
||||
INSERT OR IGNORE INTO custom_words (word)
|
||||
VALUES (?);
|
||||
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
@ -0,0 +1,3 @@
|
||||
-- 插入词
|
||||
INSERT OR IGNORE INTO all_words (word)
|
||||
VALUES (?);
|
||||
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
@ -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;
|
||||