Compare commits
30 Commits
v0.9.16
...
561f6981aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
17
Dockerfile
17
Dockerfile
@ -2,7 +2,7 @@ FROM python:3.13-slim AS base
|
|||||||
|
|
||||||
ENV VIRTUAL_ENV=/app/.venv \
|
ENV VIRTUAL_ENV=/app/.venv \
|
||||||
PATH="/app/.venv/bin:$PATH" \
|
PATH="/app/.venv/bin:$PATH" \
|
||||||
PLAYWRIGHT_BROWSERS_PATH=0
|
PLAYWRIGHT_BROWSERS_PATH=/usr/lib/pw-browsers
|
||||||
|
|
||||||
# 安装所有都需要的底层依赖
|
# 安装所有都需要的底层依赖
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@ -18,6 +18,10 @@ RUN apt-get update && \
|
|||||||
fonts-noto-color-emoji \
|
fonts-noto-color-emoji \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir playwright \
|
||||||
|
&& python -m playwright install chromium \
|
||||||
|
&& pip uninstall -y playwright
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@ -27,17 +31,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
build-essential cmake git \
|
build-essential cmake git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV POETRY_NO_INTERACTION=1 \
|
RUN pip install --no-cache-dir uv
|
||||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
|
||||||
POETRY_VIRTUALENVS_CREATE=1 \
|
|
||||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN pip install --no-cache-dir poetry
|
|
||||||
|
|
||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
RUN python -m poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
RUN uv sync --no-install-project
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -47,8 +46,6 @@ COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN python -m playwright install chromium
|
|
||||||
|
|
||||||
COPY bot.py pyproject.toml .env.prod .env.test ./
|
COPY bot.py pyproject.toml .env.prod .env.test ./
|
||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|||||||
@ -67,12 +67,16 @@ code .
|
|||||||
|
|
||||||
详见[LLM 配置文档](/docs/LLM.md)。
|
详见[LLM 配置文档](/docs/LLM.md)。
|
||||||
|
|
||||||
|
#### 配置 konabot-web 以支持更高级的图片渲染
|
||||||
|
|
||||||
|
详见[konabot-web 配置文档](/docs/konabot-web.md)
|
||||||
|
|
||||||
### 运行
|
### 运行
|
||||||
|
|
||||||
使用命令行手动启动 Bot:
|
使用命令行手动启动 Bot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
poetry run just watch
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
||||||
|
|||||||
BIN
assets/img/meme/doubao.png
Executable file
BIN
assets/img/meme/doubao.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
assets/img/meme/kiosay.jpg
Executable file
BIN
assets/img/meme/kiosay.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
18
docs/konabot-web.md
Normal file
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
4
justfile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
watch:
|
||||||
|
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
||||||
|
|
||||||
|
|
||||||
@ -8,21 +8,38 @@ import nonebot
|
|||||||
from nonebot.matcher import Matcher
|
from nonebot.matcher import Matcher
|
||||||
from nonebot.adapters import Bot, Event, Message
|
from nonebot.adapters import Bot, Event, Message
|
||||||
from nonebot.adapters.discord import Bot as DiscordBot
|
from nonebot.adapters.discord import Bot as DiscordBot
|
||||||
from nonebot.adapters.discord import GuildMessageCreateEvent as DiscordMessageEvent
|
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 Bot as OnebotV11Bot
|
||||||
from nonebot.adapters.onebot.v11 import Message as OnebotV11Message
|
from nonebot.adapters.onebot.v11 import Message as OnebotV11Message
|
||||||
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
|
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
|
||||||
import nonebot.params
|
import nonebot.params
|
||||||
from nonebot_plugin_alconna import Image, RefNode, Reply, UniMessage
|
from nonebot_plugin_alconna import Image, RefNode, Reply, UniMessage
|
||||||
from PIL import UnidentifiedImageError
|
from PIL import UnidentifiedImageError
|
||||||
|
from pydantic import BaseModel
|
||||||
from returns.result import Failure, Result, Success
|
from returns.result import Failure, Result, Success
|
||||||
|
|
||||||
|
from konabot.common.path import ASSETS_PATH
|
||||||
|
|
||||||
async def download_image_bytes(url: str) -> Result[bytes, str]:
|
|
||||||
|
discordConfig = nonebot.get_plugin_config(DiscordConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractImageConfig(BaseModel):
|
||||||
|
module_extract_image_no_download: bool = False
|
||||||
|
"要不要算了,不下载了,直接爆炸算了,适用于一些比较奇怪的网络环境,无法从协议端下载文件"
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
# if "/matcha/cache/" in url:
|
||||||
# url = url.replace('127.0.0.1', '10.126.126.101')
|
# url = url.replace('127.0.0.1', '10.126.126.101')
|
||||||
|
if module_config.module_extract_image_no_download:
|
||||||
|
return Success((ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes())
|
||||||
logger.debug(f"开始从 {url} 下载图片")
|
logger.debug(f"开始从 {url} 下载图片")
|
||||||
async with httpx.AsyncClient() as c:
|
async with httpx.AsyncClient(proxy=proxy) as c:
|
||||||
try:
|
try:
|
||||||
response = await c.get(url)
|
response = await c.get(url)
|
||||||
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
|
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
|
||||||
@ -127,8 +144,8 @@ async def extract_image_from_message(
|
|||||||
for a in evt.attachments:
|
for a in evt.attachments:
|
||||||
if "image/" not in a.content_type:
|
if "image/" not in a.content_type:
|
||||||
continue
|
continue
|
||||||
url = a.url
|
url = a.proxy_url
|
||||||
return (await download_image_bytes(url)).bind(bytes_to_pil)
|
return (await download_image_bytes(url, discordConfig.discord_proxy)).bind(bytes_to_pil)
|
||||||
|
|
||||||
for seg in UniMessage.of(msg, bot):
|
for seg in UniMessage.of(msg, bot):
|
||||||
logger.info(seg)
|
logger.info(seg)
|
||||||
|
|||||||
76
konabot/common/pager.py
Normal file
76
konabot/common/pager.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from math import ceil
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagerQuery:
|
||||||
|
page_index: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
def apply[T](self, ls: list[T]) -> "PagerResult[T]":
|
||||||
|
if self.page_size <= 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="每页元素数量应该大于 0",
|
||||||
|
data=[],
|
||||||
|
page_count=-1,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
page_count = ceil(len(ls) / self.page_size)
|
||||||
|
if self.page_index <= 0 or self.page_size <= 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="页数必须大于 0",
|
||||||
|
data=[],
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
data = ls[(self.page_index - 1) * self.page_size: self.page_index * self.page_size]
|
||||||
|
if len(data) > 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=True,
|
||||||
|
message="",
|
||||||
|
data=data,
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="指定的页数超过最大页数",
|
||||||
|
data=data,
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagerResult[T]:
|
||||||
|
data: list[T]
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
page_count: int
|
||||||
|
query: PagerQuery
|
||||||
|
|
||||||
|
def to_unimessage(
|
||||||
|
self,
|
||||||
|
formatter: Callable[[T], str | UniMessage[Any]] = str,
|
||||||
|
title: str = '查询结果',
|
||||||
|
list_indicator: str = '- ',
|
||||||
|
) -> UniMessage[Any]:
|
||||||
|
msg = UniMessage.text(f'===== {title} =====\n\n')
|
||||||
|
|
||||||
|
if not self.success:
|
||||||
|
msg = msg.text(f'⚠️ {self.message}\n')
|
||||||
|
else:
|
||||||
|
for obj in self.data:
|
||||||
|
msg = msg.text(list_indicator)
|
||||||
|
msg += formatter(obj)
|
||||||
|
msg += '\n'
|
||||||
|
|
||||||
|
msg = msg.text(f'\n===== 第 {self.query.page_index} 页,共 {self.page_count} 页 =====')
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
653
konabot/common/ptimeparse/__init__.py
Normal file
653
konabot/common/ptimeparse/__init__.py
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import re
|
||||||
|
import datetime
|
||||||
|
from typing import Tuple, Optional, Dict, Any
|
||||||
|
|
||||||
|
from .err import MultipleSpecificationException, TokenUnhandledException
|
||||||
|
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
def __init__(self, now: Optional[datetime.datetime] = None):
|
||||||
|
self.now = now or datetime.datetime.now()
|
||||||
|
|
||||||
|
def digest_chinese_number(self, text: str) -> Tuple[str, int]:
|
||||||
|
if not text:
|
||||||
|
return text, 0
|
||||||
|
# Handle "两" at start
|
||||||
|
if text.startswith("两"):
|
||||||
|
next_char = text[1] if len(text) > 1 else ''
|
||||||
|
if not next_char or next_char in "十百千万亿":
|
||||||
|
return text[1:], 2
|
||||||
|
s = "零一二三四五六七八九"
|
||||||
|
digits = {c: i for i, c in enumerate(s)}
|
||||||
|
i = 0
|
||||||
|
while i < len(text) and text[i] in s + "十百千万亿":
|
||||||
|
i += 1
|
||||||
|
if i == 0:
|
||||||
|
return text, 0
|
||||||
|
num_str = text[:i]
|
||||||
|
rest = text[i:]
|
||||||
|
|
||||||
|
def parse(s):
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
if s == "零":
|
||||||
|
return 0
|
||||||
|
if "亿" in s:
|
||||||
|
a, b = s.split("亿", 1)
|
||||||
|
return parse(a) * 100000000 + parse(b)
|
||||||
|
if "万" in s:
|
||||||
|
a, b = s.split("万", 1)
|
||||||
|
return parse(a) * 10000 + parse(b)
|
||||||
|
n = 0
|
||||||
|
t = 0
|
||||||
|
for c in s:
|
||||||
|
if c == "零":
|
||||||
|
continue
|
||||||
|
if c in digits:
|
||||||
|
t = digits[c]
|
||||||
|
elif c == "十":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 10
|
||||||
|
t = 0
|
||||||
|
elif c == "百":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 100
|
||||||
|
t = 0
|
||||||
|
elif c == "千":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 1000
|
||||||
|
t = 0
|
||||||
|
n += t
|
||||||
|
return n
|
||||||
|
|
||||||
|
return rest, parse(num_str)
|
||||||
|
|
||||||
|
def parse(self, text: str) -> datetime.datetime:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
raise TokenUnhandledException("Empty input")
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"date": None,
|
||||||
|
"time": None,
|
||||||
|
"relative_delta": None,
|
||||||
|
"am_pm": None,
|
||||||
|
"period_word": None,
|
||||||
|
"has_time": False,
|
||||||
|
"has_date": False,
|
||||||
|
"ambiguous_hour": False,
|
||||||
|
"is_24hour": False,
|
||||||
|
"has_relative_date": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = self._parse_all(text, ctx)
|
||||||
|
if rest.strip():
|
||||||
|
raise TokenUnhandledException(f"Unparsed tokens: {rest.strip()}")
|
||||||
|
|
||||||
|
return self._apply_context(ctx)
|
||||||
|
|
||||||
|
def _parse_all(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
rest = text.lstrip()
|
||||||
|
while True:
|
||||||
|
for parser in [
|
||||||
|
self._parse_absolute_date,
|
||||||
|
self._parse_relative_date,
|
||||||
|
self._parse_relative_time,
|
||||||
|
self._parse_period,
|
||||||
|
self._parse_time,
|
||||||
|
]:
|
||||||
|
new_rest = parser(rest, ctx)
|
||||||
|
if new_rest != rest:
|
||||||
|
rest = new_rest.lstrip()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return rest
|
||||||
|
|
||||||
|
def _add_delta(self, ctx, delta):
|
||||||
|
if ctx["relative_delta"] is None:
|
||||||
|
ctx["relative_delta"] = delta
|
||||||
|
else:
|
||||||
|
ctx["relative_delta"] += delta
|
||||||
|
|
||||||
|
def _parse_absolute_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d, h, minute = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["time"] = datetime.time(h, minute)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["is_24hour"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})/(\d{1,2})/(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})年(\d{1,2})月(\d{1,2})[日号]", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{1,2})月(\d{1,2})[日号]", text)
|
||||||
|
if m:
|
||||||
|
mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(.{1,3})月(.{1,3})[日号]", text)
|
||||||
|
if m:
|
||||||
|
m_str, d_str = m.groups()
|
||||||
|
_, mth = self.digest_chinese_number(m_str)
|
||||||
|
_, d = self.digest_chinese_number(d_str)
|
||||||
|
if mth == 0:
|
||||||
|
mth = 1
|
||||||
|
if d == 0:
|
||||||
|
d = 1
|
||||||
|
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_relative_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# Handle "今天", "今晚", "今早", etc.
|
||||||
|
today_variants = [
|
||||||
|
("今晚上", "PM"),
|
||||||
|
("今晚", "PM"),
|
||||||
|
("今早", "AM"),
|
||||||
|
("今天早上", "AM"),
|
||||||
|
("今天早晨", "AM"),
|
||||||
|
("今天上午", "AM"),
|
||||||
|
("今天下午", "PM"),
|
||||||
|
("今天晚上", "PM"),
|
||||||
|
("今天", None),
|
||||||
|
]
|
||||||
|
for variant, period in today_variants:
|
||||||
|
if text.startswith(variant):
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=0))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
rest = text[len(variant):]
|
||||||
|
if period is not None and ctx["am_pm"] is None:
|
||||||
|
ctx["am_pm"] = period
|
||||||
|
ctx["period_word"] = variant
|
||||||
|
return rest
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"明天": 1,
|
||||||
|
"后天": 2,
|
||||||
|
"大后天": 3,
|
||||||
|
"昨天": -1,
|
||||||
|
"前天": -2,
|
||||||
|
"大前天": -3,
|
||||||
|
}
|
||||||
|
for word, days in mapping.items():
|
||||||
|
if text.startswith(word):
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[len(word):]
|
||||||
|
m = re.match(r"^(\d+|[零一二三四五六七八九十两]+)天(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
n = int(num_str)
|
||||||
|
else:
|
||||||
|
_, n = self.digest_chinese_number(num_str)
|
||||||
|
days = n if direction in ("后", "以后", "之后") else -n
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(本|上|下)周([一二三四五六日])", text)
|
||||||
|
if m:
|
||||||
|
scope, day = m.groups()
|
||||||
|
weekday_map = {"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6}
|
||||||
|
target = weekday_map[day]
|
||||||
|
current = self.now.weekday()
|
||||||
|
if scope == "本":
|
||||||
|
delta = target - current
|
||||||
|
elif scope == "上":
|
||||||
|
delta = target - current - 7
|
||||||
|
else:
|
||||||
|
delta = target - current + 7
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=delta))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_period(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
period_mapping = {
|
||||||
|
"上午": "AM",
|
||||||
|
"早晨": "AM",
|
||||||
|
"早上": "AM",
|
||||||
|
"早": "AM",
|
||||||
|
"中午": "PM",
|
||||||
|
"下午": "PM",
|
||||||
|
"晚上": "PM",
|
||||||
|
"晚": "PM",
|
||||||
|
"凌晨": "AM",
|
||||||
|
}
|
||||||
|
for word, tag in period_mapping.items():
|
||||||
|
if text.startswith(word):
|
||||||
|
if ctx["am_pm"] is not None:
|
||||||
|
raise MultipleSpecificationException("Multiple periods")
|
||||||
|
ctx["am_pm"] = tag
|
||||||
|
ctx["period_word"] = word
|
||||||
|
return text[len(word):]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
if ctx["has_time"]:
|
||||||
|
return text
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# 1. H:MM pattern
|
||||||
|
m = re.match(r"^(\d{1,2}):(\d{2})", text)
|
||||||
|
if m:
|
||||||
|
h, minute = int(m.group(1)), int(m.group(2))
|
||||||
|
if 0 <= h <= 23 and 0 <= minute <= 59:
|
||||||
|
ctx["time"] = datetime.time(h, minute)
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["ambiguous_hour"] = 1 <= h <= 12
|
||||||
|
ctx["is_24hour"] = h > 12 or h == 0
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# 2. Parse hour part
|
||||||
|
hour = None
|
||||||
|
rest_after_hour = text
|
||||||
|
is_24hour_format = False
|
||||||
|
|
||||||
|
# Try Chinese number + 点/时
|
||||||
|
temp_rest, num = self.digest_chinese_number(text)
|
||||||
|
if num >= 0:
|
||||||
|
temp_rest_stripped = temp_rest.lstrip()
|
||||||
|
if temp_rest_stripped.startswith("点"):
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = temp_rest_stripped[1:]
|
||||||
|
elif temp_rest_stripped.startswith("时"):
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = True
|
||||||
|
rest_after_hour = temp_rest_stripped[1:]
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
m = re.match(r"^(\d{1,2})\s*([点时])", text)
|
||||||
|
if m:
|
||||||
|
hour = int(m.group(1))
|
||||||
|
is_24hour_format = m.group(2) == "时"
|
||||||
|
rest_after_hour = text[m.end():]
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
if ctx.get("am_pm") is not None:
|
||||||
|
temp_rest, num = self.digest_chinese_number(text)
|
||||||
|
if 0 <= num <= 23:
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = temp_rest.lstrip()
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
h_val = int(m.group(1))
|
||||||
|
if 0 <= h_val <= 23:
|
||||||
|
hour = h_val
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = text[m.end():].lstrip()
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
return text
|
||||||
|
|
||||||
|
if not (0 <= hour <= 23):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Parse minutes
|
||||||
|
rest = rest_after_hour.lstrip()
|
||||||
|
minute = 0
|
||||||
|
minute_spec_count = 0
|
||||||
|
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
has_zheng = False
|
||||||
|
if rest.startswith("整"):
|
||||||
|
has_zheng = True
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("半"):
|
||||||
|
minute = 30
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
if rest.startswith("整"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("一刻"):
|
||||||
|
minute = 15
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[2:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("过一刻"):
|
||||||
|
minute = 15
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[3:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
m = re.match(r"^(\d+|[零一二三四五六七八九十]+)分", rest)
|
||||||
|
if m:
|
||||||
|
minute_spec_count += 1
|
||||||
|
m_str = m.group(1)
|
||||||
|
if m_str.isdigit():
|
||||||
|
minute = int(m_str)
|
||||||
|
else:
|
||||||
|
_, minute = self.digest_chinese_number(m_str)
|
||||||
|
rest = rest[m.end():].lstrip()
|
||||||
|
|
||||||
|
if minute_spec_count == 0:
|
||||||
|
temp_rest, num = self.digest_chinese_number(rest)
|
||||||
|
if num > 0 and num <= 59:
|
||||||
|
minute = num
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = temp_rest.lstrip()
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})", rest)
|
||||||
|
if m:
|
||||||
|
m_val = int(m.group(1))
|
||||||
|
if 0 <= m_val <= 59:
|
||||||
|
minute = m_val
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[m.end():].lstrip()
|
||||||
|
|
||||||
|
if has_zheng and minute_spec_count == 0:
|
||||||
|
minute_spec_count = 1
|
||||||
|
|
||||||
|
if minute_spec_count > 1:
|
||||||
|
raise MultipleSpecificationException("Multiple minute specifications")
|
||||||
|
|
||||||
|
if not (0 <= minute <= 59):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Hours 13-23 are always 24-hour, even with "点"
|
||||||
|
if hour >= 13:
|
||||||
|
is_24hour_format = True
|
||||||
|
|
||||||
|
ctx["time"] = datetime.time(hour, minute)
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["ambiguous_hour"] = 1 <= hour <= 12 and not is_24hour_format
|
||||||
|
ctx["is_24hour"] = is_24hour_format
|
||||||
|
|
||||||
|
return rest
|
||||||
|
|
||||||
|
def _parse_relative_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# 半小时
|
||||||
|
m = re.match(r"^(半)(?:个)?小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
direction = m.group(2)
|
||||||
|
hours = 0.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X个半
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)个半(?:小时?)?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
base_hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, base_hours = self.digest_chinese_number(num_str)
|
||||||
|
if base_hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if base_hours <= 0:
|
||||||
|
return text
|
||||||
|
hours = base_hours + 0.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# 一个半
|
||||||
|
m = re.match(r"^(一个半)小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
direction = m.group(2)
|
||||||
|
hours = 1.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X小时
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, hours = self.digest_chinese_number(num_str)
|
||||||
|
if hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if hours <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, hours = self.digest_chinese_number(num_str)
|
||||||
|
if hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if hours <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction == "后" else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X分钟
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction == "后" else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction == "后" else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# === 秒级支持 ===
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
seconds = int(num_str)
|
||||||
|
else:
|
||||||
|
_, seconds = self.digest_chinese_number(num_str)
|
||||||
|
if seconds == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if seconds <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
seconds=seconds if direction in ("后", "以后", "之后") else -seconds
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
seconds = int(num_str)
|
||||||
|
else:
|
||||||
|
_, seconds = self.digest_chinese_number(num_str)
|
||||||
|
if seconds == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if seconds <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
seconds=seconds if direction == "后" else -seconds
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _apply_context(self, ctx: Dict[str, Any]) -> datetime.datetime:
|
||||||
|
result = self.now
|
||||||
|
has_date = ctx["has_date"]
|
||||||
|
has_time = ctx["has_time"]
|
||||||
|
has_delta = ctx["relative_delta"] is not None
|
||||||
|
has_relative_date = ctx["has_relative_date"]
|
||||||
|
|
||||||
|
if has_delta:
|
||||||
|
result = result + ctx["relative_delta"]
|
||||||
|
|
||||||
|
if has_date:
|
||||||
|
result = result.replace(
|
||||||
|
year=ctx["date"].year,
|
||||||
|
month=ctx["date"].month,
|
||||||
|
day=ctx["date"].day,
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_time:
|
||||||
|
h = ctx["time"].hour
|
||||||
|
m = ctx["time"].minute
|
||||||
|
|
||||||
|
if ctx["is_24hour"]:
|
||||||
|
# "10 时" → 10:00, no conversion
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif ctx["am_pm"] == "AM":
|
||||||
|
if h == 12:
|
||||||
|
h = 0
|
||||||
|
|
||||||
|
elif ctx["am_pm"] == "PM":
|
||||||
|
if h == 12:
|
||||||
|
if ctx.get("period_word") in ("晚上", "晚"):
|
||||||
|
h = 0
|
||||||
|
result += datetime.timedelta(days=1)
|
||||||
|
else:
|
||||||
|
h = 12
|
||||||
|
elif 1 <= h <= 11:
|
||||||
|
h += 12
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No period and not 24-hour (i.e., "点" format)
|
||||||
|
if ctx["has_relative_date"]:
|
||||||
|
# "明天五点" → 05:00 AM
|
||||||
|
if h == 12:
|
||||||
|
h = 0
|
||||||
|
# keep h as AM hour (1-11 unchanged)
|
||||||
|
else:
|
||||||
|
# Infer from current time
|
||||||
|
am_hour = 0 if h == 12 else h
|
||||||
|
candidate_am = result.replace(hour=am_hour, minute=m, second=0, microsecond=0)
|
||||||
|
if candidate_am < self.now:
|
||||||
|
# AM time is in the past, so use PM
|
||||||
|
if h == 12:
|
||||||
|
h = 12
|
||||||
|
else:
|
||||||
|
h += 12
|
||||||
|
# else: keep as AM (h unchanged)
|
||||||
|
|
||||||
|
if h > 23:
|
||||||
|
h = h % 24
|
||||||
|
|
||||||
|
result = result.replace(hour=h, minute=m, second=0, microsecond=0)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if has_date or (has_relative_date and not has_time):
|
||||||
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text: str) -> datetime.datetime:
|
||||||
|
return Parser().parse(text)
|
||||||
11
konabot/common/ptimeparse/err.py
Normal file
11
konabot/common/ptimeparse/err.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class PTimeParseException(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
class TokenUnhandledException(PTimeParseException):
|
||||||
|
...
|
||||||
|
|
||||||
|
class MultipleSpecificationException(PTimeParseException):
|
||||||
|
...
|
||||||
|
|
||||||
|
class OutOfRangeSpecificationException(PTimeParseException):
|
||||||
|
...
|
||||||
17
konabot/common/utils/to_async.py
Normal file
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
|
||||||
|
|
||||||
@ -1,211 +1,9 @@
|
|||||||
import asyncio
|
from .config import web_render_config
|
||||||
import queue
|
from .core import WebRenderer as WebRenderer
|
||||||
from typing import Any, Callable, Coroutine
|
from .core import WebRendererInstance as WebRendererInstance
|
||||||
from loguru import logger
|
|
||||||
from playwright.async_api import Page, Playwright, async_playwright, Browser, Page, BrowserContext
|
|
||||||
|
|
||||||
|
|
||||||
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
def konaweb(sub_url: str):
|
||||||
|
sub_url = '/' + sub_url.removeprefix('/')
|
||||||
|
return web_render_config.module_web_render_weburl.removesuffix('/') + sub_url
|
||||||
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():
|
|
||||||
instance = await WebRendererInstance.create()
|
|
||||||
cls.browser_pool.put(instance)
|
|
||||||
instance = cls.browser_pool.get()
|
|
||||||
cls.browser_pool.put(instance)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_browser_context(cls) -> BrowserContext:
|
|
||||||
instance = await cls.get_browser_instance()
|
|
||||||
if id(instance) not in cls.context_pool:
|
|
||||||
context = await instance.browser.new_context()
|
|
||||||
cls.context_pool[id(instance)] = context
|
|
||||||
logger.debug(f"Created new persistent browser context for WebRendererInstance {id(instance)}")
|
|
||||||
return cls.context_pool[id(instance)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
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_persistent_page(cls, page_id: str, url: str, target: str, params: dict = {}, other_function: callable = None, timeout: int = 30) -> bytes:
|
|
||||||
'''
|
|
||||||
使用长期挂载的页面访问指定URL并返回截图
|
|
||||||
|
|
||||||
:param page_id: 页面唯一标识符
|
|
||||||
:param url: 目标URL
|
|
||||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
|
||||||
:param timeout: 页面加载超时时间,单位秒
|
|
||||||
:param params: URL键值对参数
|
|
||||||
:param other_function: 其他自定义操作函数,接受page参数
|
|
||||||
:return: 截图的字节数据
|
|
||||||
|
|
||||||
'''
|
|
||||||
logger.debug(f"Requesting persistent render for page_id {page_id} at {url} targeting {target} with timeout {timeout}")
|
|
||||||
instance = await cls.get_browser_instance()
|
|
||||||
if page_id not in cls.page_pool:
|
|
||||||
context = await cls.get_browser_context()
|
|
||||||
page = await context.new_page()
|
|
||||||
cls.page_pool[page_id] = page
|
|
||||||
logger.debug(f"Created new persistent page for page_id {page_id} using WebRendererInstance {id(instance)}")
|
|
||||||
page = cls.page_pool[page_id]
|
|
||||||
return await instance.render_with_page(page, url, target, params=params, other_function=other_function, timeout=timeout)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
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 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:
|
|
||||||
def __init__(self):
|
|
||||||
self._playwright: Playwright | None = None
|
|
||||||
self._browser: Browser | None = None
|
|
||||||
self.lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@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 render(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
target: str,
|
|
||||||
index: int = 0,
|
|
||||||
params: dict = {},
|
|
||||||
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, other_function, timeout)
|
|
||||||
await page.close()
|
|
||||||
await context.close()
|
|
||||||
return screenshot
|
|
||||||
|
|
||||||
async def render_with_page(self, page: Page, url: str, target: str, index: int = 0, params: dict = {}, other_function: callable = None, timeout: int = 30) -> bytes:
|
|
||||||
async with self.lock:
|
|
||||||
screenshot = await self.inner_render(page, url, target, index, params, other_function, timeout)
|
|
||||||
return screenshot
|
|
||||||
|
|
||||||
async def render_file(self, file_path: str, target: str, index: int = 0, params: dict = {}, other_function: callable = None, timeout: int = 30) -> bytes:
|
|
||||||
file_path = "file:///" + str(file_path).replace("\\", "/")
|
|
||||||
return await self.render(file_path, target, index, params, other_function, timeout)
|
|
||||||
|
|
||||||
async def inner_render(self, page: Page, url: str, target: str, index: int = 0, params: dict = {}, other_function: callable = 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.error(f"Target element '{target}' not found on the page.")
|
|
||||||
return None
|
|
||||||
if index >= len(elements):
|
|
||||||
logger.error(f"Index {index} out of range for elements matching '{target}'")
|
|
||||||
return None
|
|
||||||
element = elements[index]
|
|
||||||
screenshot = await element.screenshot()
|
|
||||||
logger.debug(f"Screenshot taken successfully")
|
|
||||||
return screenshot
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
await self.browser.close()
|
|
||||||
await self.playwright.stop()
|
|
||||||
|
|
||||||
|
|||||||
20
konabot/common/web_render/config.py
Normal file
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
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
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())
|
||||||
|
|
||||||
22
konabot/docs/user/订阅.txt
Normal file
22
konabot/docs/user/订阅.txt
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
指令介绍
|
||||||
|
订阅 - 收听此方 BOT 的自动消息发送
|
||||||
|
|
||||||
|
格式
|
||||||
|
订阅 <频道名称>
|
||||||
|
取消订阅 <频道名称>
|
||||||
|
查询订阅 [页码]
|
||||||
|
可用订阅 [页码]
|
||||||
|
|
||||||
|
示例
|
||||||
|
`订阅 此方谜题`
|
||||||
|
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
|
||||||
|
如果你是私聊,则能够每天发送此方谜题到你的私聊;如果在群聊中使用该指令,则会每天发送题目到这个群里面。
|
||||||
|
|
||||||
|
`取消订阅 此方谜题`
|
||||||
|
取消订阅「此方谜题」频道。
|
||||||
|
|
||||||
|
`查询订阅`
|
||||||
|
查询当前聊天上下文订阅的所有频道。
|
||||||
|
|
||||||
|
`可用订阅 2`
|
||||||
|
查询所有可用的订阅的第二页
|
||||||
@ -21,10 +21,13 @@ from nonebot_plugin_alconna import (
|
|||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.path import ASSETS_PATH
|
from konabot.common.path import ASSETS_PATH
|
||||||
|
|
||||||
DATA_FILE_PATH = (
|
from konabot.common.llm import get_llm
|
||||||
Path(__file__).parent.parent.parent.parent / "data" / "idiom_banned.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
|
||||||
|
|
||||||
|
DATA_FILE_PATH = (
|
||||||
|
DATA_DIR / "idiom_banned.json"
|
||||||
|
)
|
||||||
|
|
||||||
def load_banned_ids() -> list[str]:
|
def load_banned_ids() -> list[str]:
|
||||||
if not DATA_FILE_PATH.exists():
|
if not DATA_FILE_PATH.exists():
|
||||||
@ -75,6 +78,27 @@ class TryVerifyState(Enum):
|
|||||||
BUT_NO_NEXT = 5
|
BUT_NO_NEXT = 5
|
||||||
GAME_END = 6
|
GAME_END = 6
|
||||||
|
|
||||||
|
class IdiomGameLLM:
|
||||||
|
@classmethod
|
||||||
|
async def verify_idiom_with_llm(cls, idiom: str) -> bool:
|
||||||
|
if len(idiom) != 4:
|
||||||
|
return False
|
||||||
|
llm = get_llm()
|
||||||
|
system_prompt = "请判断用户的输入是否为一个合理的成语,或者这四个字在中文环境下是否说得通。如果是请回答「T」,否则回答「F」。请注意,即使这个词不是成语,如果说得通(也就是能念起来很通顺),你也该输出「T」。请不要包含任何解释,也不要包含任何标点符号。"
|
||||||
|
message = await llm.chat([{"role": "system", "content": system_prompt}, {"role": "user", "content": idiom}])
|
||||||
|
answer = message.content
|
||||||
|
logger.info(f"LLM 对成语 {idiom} 的判断结果是 {answer}")
|
||||||
|
if answer == "T":
|
||||||
|
await cls.storage_idiom(idiom)
|
||||||
|
return answer == "T"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def storage_idiom(cls, idiom: str):
|
||||||
|
# 将 idiom 存入本地文件以备后续分析
|
||||||
|
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:
|
class IdiomGame:
|
||||||
ALL_WORDS = [] # 所有四字词语
|
ALL_WORDS = [] # 所有四字词语
|
||||||
@ -101,6 +125,17 @@ class IdiomGame:
|
|||||||
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
|
self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链
|
||||||
IdiomGame.INSTANCE_LIST[group_id] = self
|
IdiomGame.INSTANCE_LIST[group_id] = self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
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)
|
||||||
|
|
||||||
def be_able_to_play(self) -> bool:
|
def be_able_to_play(self) -> bool:
|
||||||
if self.last_play_date != datetime.date.today():
|
if self.last_play_date != datetime.date.today():
|
||||||
self.last_play_date = datetime.date.today()
|
self.last_play_date = datetime.date.today()
|
||||||
@ -186,7 +221,7 @@ class IdiomGame:
|
|||||||
用户发送成语
|
用户发送成语
|
||||||
"""
|
"""
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
state = self._verify_idiom(idiom, user_id)
|
state = await self._verify_idiom(idiom, user_id)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def is_nextable(self, last_char: str) -> bool:
|
def is_nextable(self, last_char: str) -> bool:
|
||||||
@ -218,16 +253,24 @@ class IdiomGame:
|
|||||||
result.append(" -> ".join(chain))
|
result.append(" -> ".join(chain))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
async def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]:
|
||||||
state = []
|
state = []
|
||||||
# 新成语的首字应与上一条成语的尾字相同
|
# 新成语的首字应与上一条成语的尾字相同
|
||||||
if idiom[0] != self.last_char:
|
if idiom[0] != self.last_char:
|
||||||
state.append(TryVerifyState.WRONG_FIRST_CHAR)
|
state.append(TryVerifyState.WRONG_FIRST_CHAR)
|
||||||
return state
|
return state
|
||||||
if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
|
if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
|
||||||
self.add_score(user_id, -0.1)
|
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
|
||||||
state.append(TryVerifyState.NOT_IDIOM)
|
try:
|
||||||
return state
|
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
|
||||||
|
self.add_score(user_id, -0.1)
|
||||||
|
state.append(TryVerifyState.NOT_IDIOM)
|
||||||
|
return state
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM 验证成语 {idiom} 时出现错误:{e}")
|
||||||
|
self.add_score(user_id, -0.1)
|
||||||
|
state.append(TryVerifyState.NOT_IDIOM)
|
||||||
|
return state
|
||||||
# 成语合法,更新状态
|
# 成语合法,更新状态
|
||||||
self.add_history_idiom(idiom)
|
self.add_history_idiom(idiom)
|
||||||
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
|
score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半
|
||||||
@ -335,6 +378,16 @@ class IdiomGame:
|
|||||||
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
|
logger.debug(f"Loaded {len(THUOCL_WORDS)} words from THUOCL txt files")
|
||||||
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
|
logger.debug(f"Sample words: {THUOCL_WORDS[:5]}")
|
||||||
|
|
||||||
|
# 读取本地的 idiom_llm_storage.txt 文件,补充词语表
|
||||||
|
LOCAL_LLM_WORDS = []
|
||||||
|
if (DATA_DIR / "idiom_llm_storage.txt").exists():
|
||||||
|
with open(DATA_DIR / "idiom_llm_storage.txt", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
word = line.strip()
|
||||||
|
if len(word) == 4:
|
||||||
|
LOCAL_LLM_WORDS.append(word)
|
||||||
|
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
|
||||||
|
|
||||||
# 只有成语的大表
|
# 只有成语的大表
|
||||||
cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
|
cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
|
||||||
cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
|
cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
|
||||||
@ -344,6 +397,7 @@ class IdiomGame:
|
|||||||
[word for word in cls.ALL_WORDS if len(word) == 4]
|
[word for word in cls.ALL_WORDS if len(word) == 4]
|
||||||
+ THUOCL_WORDS
|
+ THUOCL_WORDS
|
||||||
+ COMMON_WORDS
|
+ COMMON_WORDS
|
||||||
|
+ LOCAL_LLM_WORDS
|
||||||
)
|
)
|
||||||
cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
|
cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
|
||||||
|
|
||||||
|
|||||||
@ -4,33 +4,32 @@ from math import ceil
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import on_message
|
from nonebot import on_message
|
||||||
|
import nonebot
|
||||||
|
from nonebot.rule import to_me
|
||||||
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
|
from nonebot_plugin_alconna import (Alconna, Args, UniMessage, UniMsg,
|
||||||
on_alconna)
|
on_alconna)
|
||||||
from nonebot_plugin_apscheduler import scheduler
|
from nonebot_plugin_apscheduler import scheduler
|
||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
from konabot.common.nb.qq_broadcast import qq_broadcast
|
|
||||||
from konabot.plugins.kona_ph.core.message import (get_daily_report,
|
from konabot.plugins.kona_ph.core.message import (get_daily_report,
|
||||||
get_daily_report_v2,
|
get_daily_report_v2,
|
||||||
get_puzzle_description,
|
get_puzzle_description,
|
||||||
get_submission_message)
|
get_submission_message)
|
||||||
from konabot.plugins.kona_ph.core.storage import get_today_date
|
from konabot.plugins.kona_ph.core.storage import get_today_date
|
||||||
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE, config,
|
from konabot.plugins.kona_ph.manager import (PUZZLE_PAGE_SIZE,
|
||||||
create_admin_commands,
|
create_admin_commands,
|
||||||
puzzle_manager)
|
puzzle_manager)
|
||||||
|
from konabot.plugins.poster.poster_info import PosterInfo, register_poster_info
|
||||||
|
from konabot.plugins.poster.service import broadcast
|
||||||
|
|
||||||
create_admin_commands()
|
create_admin_commands()
|
||||||
|
register_poster_info("每日谜题", info=PosterInfo(
|
||||||
|
aliases={"konaph", "kona_ph", "KonaPH", "此方谜题", "KONAPH"},
|
||||||
|
description="此方 BOT 每日谜题推送",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
async def is_play_group(target: DepLongTaskTarget):
|
cmd_submit = on_message(rule=to_me())
|
||||||
if target.is_private_chat:
|
|
||||||
return True
|
|
||||||
if target.channel_id in config.plugin_puzzle_playgroup:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
cmd_submit = on_message(rule=is_play_group)
|
|
||||||
|
|
||||||
|
|
||||||
@cmd_submit.handle()
|
@cmd_submit.handle()
|
||||||
@ -52,7 +51,7 @@ async def _(msg: UniMsg, target: DepLongTaskTarget):
|
|||||||
|
|
||||||
cmd_query = on_alconna(Alconna(
|
cmd_query = on_alconna(Alconna(
|
||||||
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
||||||
), rule=is_play_group)
|
), rule=to_me())
|
||||||
|
|
||||||
@cmd_query.handle()
|
@cmd_query.handle()
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
@ -65,7 +64,7 @@ async def _(target: DepLongTaskTarget):
|
|||||||
|
|
||||||
cmd_query_submission = on_alconna(Alconna(
|
cmd_query_submission = on_alconna(Alconna(
|
||||||
"今日答题情况"
|
"今日答题情况"
|
||||||
), rule=is_play_group)
|
), rule=to_me())
|
||||||
|
|
||||||
@cmd_query_submission.handle()
|
@cmd_query_submission.handle()
|
||||||
async def _(target: DepLongTaskTarget):
|
async def _(target: DepLongTaskTarget):
|
||||||
@ -80,7 +79,7 @@ cmd_history = on_alconna(Alconna(
|
|||||||
"历史题目",
|
"历史题目",
|
||||||
Args["page?", int],
|
Args["page?", int],
|
||||||
Args["index_id?", str],
|
Args["index_id?", str],
|
||||||
), rule=is_play_group)
|
), rule=to_me())
|
||||||
|
|
||||||
@cmd_history.handle()
|
@cmd_history.handle()
|
||||||
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
||||||
@ -125,11 +124,15 @@ async def _():
|
|||||||
yesterday = get_today_date() - datetime.timedelta(days=1)
|
yesterday = get_today_date() - datetime.timedelta(days=1)
|
||||||
msg2 = get_daily_report(manager, yesterday)
|
msg2 = get_daily_report(manager, yesterday)
|
||||||
if msg2 is not None:
|
if msg2 is not None:
|
||||||
await qq_broadcast(config.plugin_puzzle_playgroup, msg2)
|
await broadcast("每日谜题", msg2)
|
||||||
|
|
||||||
puzzle = manager.get_today_puzzle()
|
puzzle = manager.get_today_puzzle()
|
||||||
if puzzle is not None:
|
if puzzle is not None:
|
||||||
logger.info(f"找到了题目 {puzzle.raw_id},发送")
|
logger.info(f"找到了题目 {puzzle.raw_id},发送")
|
||||||
await qq_broadcast(config.plugin_puzzle_playgroup, get_puzzle_description(puzzle))
|
await broadcast("每日谜题", get_puzzle_description(puzzle))
|
||||||
else:
|
else:
|
||||||
logger.info("自动任务:没有找到题目,跳过")
|
logger.info("自动任务:没有找到题目,跳过")
|
||||||
|
|
||||||
|
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|||||||
72
konabot/plugins/markdown/__init__.py
Normal file
72
konabot/plugins/markdown/__init__.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from loguru import logger
|
||||||
|
import nonebot
|
||||||
|
from nonebot.adapters import Event as BaseEvent
|
||||||
|
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||||
|
from nonebot_plugin_alconna import (
|
||||||
|
UniMessage,
|
||||||
|
UniMsg
|
||||||
|
)
|
||||||
|
|
||||||
|
from konabot.plugins.markdown.core import MarkDownCore
|
||||||
|
|
||||||
|
def is_markdown_mentioned(msg: UniMsg) -> bool:
|
||||||
|
txt = msg.extract_plain_text()
|
||||||
|
if "markdown" not in txt[:8] and "md" not in txt[:2]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
evt = nonebot.on_message(rule=is_markdown_mentioned)
|
||||||
|
|
||||||
|
@evt.handle()
|
||||||
|
async def _(msg: UniMsg, event: BaseEvent):
|
||||||
|
if isinstance(event, DiscordMessageEvent):
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
else:
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
|
||||||
|
logger.debug(f"Received markdown command with content: {content}")
|
||||||
|
if "md" in content[:2]:
|
||||||
|
message = content.replace("md", "", 1).strip()
|
||||||
|
else:
|
||||||
|
message = content.replace("markdown", "", 1).strip()
|
||||||
|
# 如果回复了消息,则转换回复的内容
|
||||||
|
if(len(message) == 0):
|
||||||
|
if event.reply:
|
||||||
|
message = event.reply.message.extract_plain_text()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
logger.debug(f"Markdown content to render: {message}")
|
||||||
|
|
||||||
|
out = await MarkDownCore.render_markdown(message, theme="dark")
|
||||||
|
|
||||||
|
await evt.send(await UniMessage().image(raw=out).export())
|
||||||
|
|
||||||
|
|
||||||
|
def is_latex_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
|
||||||
|
txt = msg.extract_plain_text()
|
||||||
|
if "latex" not in txt[:5]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
evt = nonebot.on_message(rule=is_latex_mentioned)
|
||||||
|
|
||||||
|
@evt.handle()
|
||||||
|
async def _(msg: UniMsg, event: BaseEvent):
|
||||||
|
if isinstance(event, DiscordMessageEvent):
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
else:
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
|
||||||
|
logger.debug(f"Received markdown command with content: {content}")
|
||||||
|
message = content.replace("latex", "", 1).strip()
|
||||||
|
# 如果回复了消息,则转换回复的内容
|
||||||
|
if(len(message) == 0):
|
||||||
|
if event.reply:
|
||||||
|
message = event.reply.message.extract_plain_text()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
logger.debug(f"Latex content to render: {message}")
|
||||||
|
|
||||||
|
out = await MarkDownCore.render_latex(message, theme="dark")
|
||||||
|
|
||||||
|
await evt.send(await UniMessage().image(raw=out).export())
|
||||||
57
konabot/plugins/markdown/core.py
Normal file
57
konabot/plugins/markdown/core.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from loguru import logger
|
||||||
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
|
from konabot.common.web_render import konaweb
|
||||||
|
from konabot.common.web_render.core import WebRenderer
|
||||||
|
|
||||||
|
class MarkDownCore:
|
||||||
|
@staticmethod
|
||||||
|
async def render_markdown(markdown_text: str, theme: str = "dark", params: dict = {}) -> bytes:
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.emulate_media(color_scheme=theme)
|
||||||
|
|
||||||
|
await page.locator('textarea[name=content]').fill(markdown_text)
|
||||||
|
await page.locator('#button').click()
|
||||||
|
|
||||||
|
# 等待 checkState 函数加载完成
|
||||||
|
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
|
||||||
|
# 访问 checkState 函数,确保渲染完成
|
||||||
|
await page.wait_for_function("checkState() === true", timeout=1000)
|
||||||
|
|
||||||
|
out = await WebRenderer.render_with_persistent_page(
|
||||||
|
"markdown_renderer",
|
||||||
|
konaweb('markdown'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def render_latex(text: str, theme: str = "dark") -> bytes:
|
||||||
|
params = {
|
||||||
|
"size": "2.5em",
|
||||||
|
}
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.emulate_media(color_scheme=theme)
|
||||||
|
|
||||||
|
page.wait_for_selector('textarea[name=content]')
|
||||||
|
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
|
||||||
|
page.wait_for_selector('#button')
|
||||||
|
await page.locator('#button').click()
|
||||||
|
|
||||||
|
# 等待 checkState 函数加载完成
|
||||||
|
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
|
||||||
|
# 访问 checkState 函数,确保渲染完成
|
||||||
|
await page.wait_for_function("checkState() === true", timeout=10000)
|
||||||
|
|
||||||
|
out = await WebRenderer.render_with_persistent_page(
|
||||||
|
"latex_renderer",
|
||||||
|
konaweb('latex'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
@ -1,6 +1,7 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Iterable, cast
|
from typing import Iterable, cast
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from nonebot import on_message
|
from nonebot import on_message
|
||||||
from nonebot_plugin_alconna import (
|
from nonebot_plugin_alconna import (
|
||||||
Alconna,
|
Alconna,
|
||||||
@ -14,8 +15,12 @@ from nonebot_plugin_alconna import (
|
|||||||
UniMsg,
|
UniMsg,
|
||||||
on_alconna,
|
on_alconna,
|
||||||
)
|
)
|
||||||
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
from konabot.common.nb.extract_image import PIL_Image, extract_image_from_message
|
from konabot.common.nb.extract_image import PIL_Image, extract_image_from_message
|
||||||
|
from konabot.common.web_render import konaweb
|
||||||
|
from konabot.common.web_render.core import WebRenderer
|
||||||
|
from konabot.common.web_render.host_images import host_tempdir
|
||||||
from konabot.plugins.memepack.drawing.display import (
|
from konabot.plugins.memepack.drawing.display import (
|
||||||
draw_cao_display,
|
draw_cao_display,
|
||||||
draw_snaur_display,
|
draw_snaur_display,
|
||||||
@ -24,10 +29,12 @@ from konabot.plugins.memepack.drawing.display import (
|
|||||||
from konabot.plugins.memepack.drawing.saying import (
|
from konabot.plugins.memepack.drawing.saying import (
|
||||||
draw_cute_ten,
|
draw_cute_ten,
|
||||||
draw_geimao,
|
draw_geimao,
|
||||||
|
draw_kiosay,
|
||||||
draw_mnk,
|
draw_mnk,
|
||||||
draw_pt,
|
draw_pt,
|
||||||
draw_suan,
|
draw_suan,
|
||||||
)
|
)
|
||||||
|
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
|
||||||
|
|
||||||
from nonebot.adapters import Bot, Event
|
from nonebot.adapters import Bot, Event
|
||||||
|
|
||||||
@ -275,3 +282,77 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
|
|||||||
.export()
|
.export()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
kiosay = on_alconna(
|
||||||
|
Alconna(
|
||||||
|
"西多说",
|
||||||
|
Args[
|
||||||
|
"saying",
|
||||||
|
MultiVar(str, "+"),
|
||||||
|
Field(missing_tips=lambda: "你没有写西多说了什么"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
use_cmd_start=True,
|
||||||
|
use_cmd_sep=False,
|
||||||
|
skip_for_unmatch=False,
|
||||||
|
aliases=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@kiosay.handle()
|
||||||
|
async def _(saying: list[str]):
|
||||||
|
img = await draw_kiosay("\n".join(saying))
|
||||||
|
img_bytes = BytesIO()
|
||||||
|
img.save(img_bytes, format="PNG")
|
||||||
|
|
||||||
|
await kiosay.send(await UniMessage().image(raw=img_bytes).export())
|
||||||
|
|
||||||
|
|
||||||
|
quote_cmd = on_alconna(Alconna(
|
||||||
|
"名人名言",
|
||||||
|
Args["quote", str],
|
||||||
|
Args["author", str],
|
||||||
|
Args["image?", Image | None],
|
||||||
|
), aliases={"quote"})
|
||||||
|
|
||||||
|
@quote_cmd.handle()
|
||||||
|
async def _(quote: str, author: str, img: PIL_Image):
|
||||||
|
async with host_tempdir() as tempdir:
|
||||||
|
img_path = tempdir.path / "image.png"
|
||||||
|
img_url = tempdir.url_of(img_path)
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
async def page_function(page: Page):
|
||||||
|
async def on_console(msg: ConsoleMessage):
|
||||||
|
logger.debug(f"WEB CONSOLE {msg.text}")
|
||||||
|
|
||||||
|
page.on('console', on_console)
|
||||||
|
|
||||||
|
await page.locator('input[name=image]').fill(img_url)
|
||||||
|
await page.locator('input[name=quote]').fill(quote)
|
||||||
|
await page.locator('input[name=author]').fill(author)
|
||||||
|
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
await page.wait_for_load_state('networkidle')
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
out = await WebRenderer.render(
|
||||||
|
konaweb('makequote'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
)
|
||||||
|
await quote_cmd.send(await UniMessage().image(raw=out).export())
|
||||||
|
|
||||||
|
|
||||||
|
doubao_cmd = on_alconna(Alconna(
|
||||||
|
"豆包水印",
|
||||||
|
Args["image?", Image | None],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@doubao_cmd.handle()
|
||||||
|
async def _(img: PIL_Image):
|
||||||
|
result = await draw_doubao_watermark(img)
|
||||||
|
result_bytes = BytesIO()
|
||||||
|
result.save(result_bytes, format="PNG")
|
||||||
|
await doubao_cmd.send(await UniMessage().image(raw=result_bytes).export())
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import imagetext_py
|
|||||||
import PIL.Image
|
import PIL.Image
|
||||||
|
|
||||||
from konabot.common.path import ASSETS_PATH
|
from konabot.common.path import ASSETS_PATH
|
||||||
|
from konabot.common.utils.to_async import make_async
|
||||||
|
|
||||||
from .base.fonts import HARMONYOS_SANS_SC_BLACK, HARMONYOS_SANS_SC_REGULAR, LXGWWENKAI_REGULAR
|
from .base.fonts import HARMONYOS_SANS_SC_BLACK, HARMONYOS_SANS_SC_REGULAR, LXGWWENKAI_REGULAR
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ mnk_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "mnksay.jpg").convert(
|
|||||||
dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert("RGBA")
|
dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert("RGBA")
|
||||||
suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert("RGBA")
|
suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert("RGBA")
|
||||||
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert("RGBA")
|
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert("RGBA")
|
||||||
|
kio_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "kiosay.jpg").convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
def _draw_geimao(saying: str):
|
def _draw_geimao(saying: str):
|
||||||
@ -29,7 +31,7 @@ def _draw_geimao(saying: str):
|
|||||||
draw_emojis=True,
|
draw_emojis=True,
|
||||||
)
|
)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
async def draw_geimao(saying: str):
|
async def draw_geimao(saying: str):
|
||||||
return await asyncio.to_thread(_draw_geimao, saying)
|
return await asyncio.to_thread(_draw_geimao, saying)
|
||||||
@ -106,3 +108,18 @@ def _draw_cute_ten(saying: str):
|
|||||||
|
|
||||||
async def draw_cute_ten(saying: str):
|
async def draw_cute_ten(saying: str):
|
||||||
return await asyncio.to_thread(_draw_cute_ten, saying)
|
return await asyncio.to_thread(_draw_cute_ten, saying)
|
||||||
|
|
||||||
|
|
||||||
|
@make_async
|
||||||
|
def draw_kiosay(saying: str):
|
||||||
|
img = kio_image.copy()
|
||||||
|
with imagetext_py.Writer(img) as iw:
|
||||||
|
iw.draw_text_wrapped(
|
||||||
|
saying, 450, 540, 0.5, 0.5, 900, 96, LXGWWENKAI_REGULAR,
|
||||||
|
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||||
|
1.0,
|
||||||
|
imagetext_py.TextAlign.Center,
|
||||||
|
draw_emojis=True,
|
||||||
|
)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|||||||
20
konabot/plugins/memepack/drawing/watermark.py
Normal file
20
konabot/plugins/memepack/drawing/watermark.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import PIL
|
||||||
|
import PIL.Image
|
||||||
|
|
||||||
|
from konabot.common.path import ASSETS_PATH
|
||||||
|
from konabot.common.utils.to_async import make_async
|
||||||
|
|
||||||
|
doubao_watermark = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "doubao.png").convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
@make_async
|
||||||
|
def draw_doubao_watermark(base: PIL.Image.Image) -> PIL.Image.Image:
|
||||||
|
base = base.copy().convert("RGBA")
|
||||||
|
w = base.size[0] / 768 * 140
|
||||||
|
h = base.size[0] / 768 * 40
|
||||||
|
x = base.size[0] / 768 * 160
|
||||||
|
y = base.size[0] / 768 * 60
|
||||||
|
w, h, x, y = map(int, (w, h, x, y))
|
||||||
|
base.alpha_composite(doubao_watermark.resize((w, h)), (base.size[0] - x, base.size[1] - y))
|
||||||
|
return base
|
||||||
|
|
||||||
85
konabot/plugins/poster/__init__.py
Normal file
85
konabot/plugins/poster/__init__.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import nonebot
|
||||||
|
from nonebot_plugin_alconna import Alconna, Args, on_alconna
|
||||||
|
|
||||||
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery
|
||||||
|
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
||||||
|
from konabot.plugins.poster.service import dep_poster_service
|
||||||
|
|
||||||
|
|
||||||
|
cmd_subscribe = on_alconna(Alconna(
|
||||||
|
"订阅",
|
||||||
|
Args["channel", str],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_subscribe.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, channel: str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.subscribe(channel, target)
|
||||||
|
if result:
|
||||||
|
await target.send_message(f"已订阅「{channel}」")
|
||||||
|
else:
|
||||||
|
await target.send_message(f"已经订阅过「{channel}」了")
|
||||||
|
|
||||||
|
|
||||||
|
cmd_list = on_alconna(Alconna(
|
||||||
|
"re:(?:查询|我的|获取)订阅(列表)?",
|
||||||
|
Args["page?", int],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def better_channel_message(channel_id: str) -> str:
|
||||||
|
if channel_id not in POSTER_INFO_DATA:
|
||||||
|
return channel_id
|
||||||
|
data = POSTER_INFO_DATA[channel_id]
|
||||||
|
return f"{channel_id}:{data.description}"
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_list.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.get_channels(target, PagerQuery(
|
||||||
|
page_index=page,
|
||||||
|
page_size=10,
|
||||||
|
))
|
||||||
|
await target.send_message(result.to_unimessage(title="订阅列表", formatter=better_channel_message))
|
||||||
|
|
||||||
|
|
||||||
|
cmd_list_available = on_alconna(Alconna(
|
||||||
|
"re:(查询)?可用订阅(列表)?",
|
||||||
|
Args["page?", int],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_list_available.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
|
result = PagerQuery(
|
||||||
|
page_index=page,
|
||||||
|
page_size=10,
|
||||||
|
).apply(sorted(POSTER_INFO_DATA.keys()))
|
||||||
|
await target.send_message(result.to_unimessage(title="可用订阅列表", formatter=better_channel_message))
|
||||||
|
|
||||||
|
|
||||||
|
cmd_unsubscribe = on_alconna(Alconna(
|
||||||
|
"取消订阅",
|
||||||
|
Args["channel", str],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_unsubscribe.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, channel: str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.subscribe(channel, target)
|
||||||
|
if result:
|
||||||
|
await target.send_message(f"已取消订阅「{channel}」")
|
||||||
|
else:
|
||||||
|
await target.send_message(f"这里没有订阅过「{channel}」")
|
||||||
|
|
||||||
|
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
@driver.on_startup
|
||||||
|
async def _():
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
await service.fix_data()
|
||||||
15
konabot/plugins/poster/poster_info.py
Normal file
15
konabot/plugins/poster/poster_info.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PosterInfo:
|
||||||
|
aliases: set[str] = field(default_factory=set)
|
||||||
|
description: str = field(default='')
|
||||||
|
|
||||||
|
|
||||||
|
POSTER_INFO_DATA: dict[str, PosterInfo] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_poster_info(channel: str, info: PosterInfo):
|
||||||
|
POSTER_INFO_DATA[channel] = info
|
||||||
|
|
||||||
112
konabot/plugins/poster/repo_local_data.py
Normal file
112
konabot/plugins/poster/repo_local_data.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
from konabot.common.path import DATA_PATH
|
||||||
|
from konabot.plugins.poster.repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelData(BaseModel):
|
||||||
|
targets: list[LongTaskTarget] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PosterData(BaseModel):
|
||||||
|
channels: dict[str, ChannelData] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_the_same_target(target1: LongTaskTarget, target2: LongTaskTarget) -> bool:
|
||||||
|
if (target1.is_private_chat and not target2.is_private_chat):
|
||||||
|
return False
|
||||||
|
if (target2.is_private_chat and not target1.is_private_chat):
|
||||||
|
return False
|
||||||
|
if target1.platform != target2.platform:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 如果是群聊,则要求 channel_id 相同
|
||||||
|
if not target1.is_private_chat:
|
||||||
|
return target1.channel_id == target2.channel_id
|
||||||
|
return target1.target_id == target2.target_id
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPosterRepo(IPosterRepo):
|
||||||
|
def __init__(self, data: PosterData) -> None:
|
||||||
|
self.data = data
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
|
||||||
|
if channel not in self.data.channels:
|
||||||
|
self.data.channels[channel] = ChannelData()
|
||||||
|
return self.data.channels[channel].targets
|
||||||
|
|
||||||
|
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
targets = await self.get_channel_targets(channel)
|
||||||
|
for t in targets:
|
||||||
|
if is_the_same_target(t, target):
|
||||||
|
return False
|
||||||
|
targets.append(target)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
targets = await self.get_channel_targets(channel)
|
||||||
|
len0 = len(targets)
|
||||||
|
self.data.channels[channel].targets = [
|
||||||
|
t for t in targets if not is_the_same_target(t, target)
|
||||||
|
]
|
||||||
|
len1 = len(self.data.channels[channel].targets)
|
||||||
|
return len0 != len1
|
||||||
|
|
||||||
|
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
channels: list[str] = []
|
||||||
|
for channel_id, channel in self.data.channels.items():
|
||||||
|
for t in channel.targets:
|
||||||
|
if is_the_same_target(target, t):
|
||||||
|
channels.append(channel_id)
|
||||||
|
break
|
||||||
|
channels = sorted(channels)
|
||||||
|
return pager.apply(channels)
|
||||||
|
|
||||||
|
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
|
||||||
|
channel_from = await self.get_channel_targets(from_channel)
|
||||||
|
channel_to = await self.get_channel_targets(to_channel)
|
||||||
|
|
||||||
|
for t1 in channel_from:
|
||||||
|
flag = True
|
||||||
|
for t2 in channel_to:
|
||||||
|
if is_the_same_target(t1, t2):
|
||||||
|
flag = False
|
||||||
|
break
|
||||||
|
if flag:
|
||||||
|
channel_to.append(t1)
|
||||||
|
|
||||||
|
del self.data.channels[from_channel]
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_POSTER_DATA_LOCK = asyncio.Lock()
|
||||||
|
LOCAL_POSTER_DATA_PATH = DATA_PATH / "module_poster_data.json"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def local_poster_data():
|
||||||
|
async with LOCAL_POSTER_DATA_LOCK:
|
||||||
|
if not LOCAL_POSTER_DATA_PATH.exists():
|
||||||
|
data = PosterData()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = PosterData.model_validate_json(LOCAL_POSTER_DATA_PATH.read_text())
|
||||||
|
except ValidationError:
|
||||||
|
data = PosterData()
|
||||||
|
yield data
|
||||||
|
LOCAL_POSTER_DATA_PATH.write_text(data.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def local_poster():
|
||||||
|
async with local_poster_data() as data:
|
||||||
|
yield LocalPosterRepo(data)
|
||||||
|
|
||||||
|
|
||||||
|
DepLocalPosterRepo = Annotated[LocalPosterRepo, Depends(local_poster)]
|
||||||
|
|
||||||
37
konabot/plugins/poster/repository.py
Normal file
37
konabot/plugins/poster/repository.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
|
||||||
|
|
||||||
|
class IPosterRepo(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
|
||||||
|
"""
|
||||||
|
获取广播通道的所有广播对象
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
"""
|
||||||
|
向广播通道添加一个广播目标。若目标已存在,则返回 False
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
"""
|
||||||
|
移除一个广播通道的目标。若目标不存在,则返回 False
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
"""
|
||||||
|
获得一个目标已经订阅了的广播通道
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
|
||||||
|
"""
|
||||||
|
合并两个 Channel 为一个,并移除另一个
|
||||||
|
"""
|
||||||
|
|
||||||
59
konabot/plugins/poster/service.py
Normal file
59
konabot/plugins/poster/service.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
||||||
|
from konabot.plugins.poster.repo_local_data import local_poster
|
||||||
|
from konabot.plugins.poster.repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
|
class PosterService:
|
||||||
|
def __init__(self, repo: IPosterRepo) -> None:
|
||||||
|
self.repo = repo
|
||||||
|
|
||||||
|
def parse_channel_id(self, channel: str):
|
||||||
|
for cid, cinfo in POSTER_INFO_DATA.items():
|
||||||
|
if channel in cinfo.aliases:
|
||||||
|
return cid
|
||||||
|
return channel
|
||||||
|
|
||||||
|
async def subscribe(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
return await self.repo.add_channel_target(channel, target)
|
||||||
|
|
||||||
|
async def unsubscribe(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
return await self.repo.remove_channel_target(channel, target)
|
||||||
|
|
||||||
|
async def broadcast(self, channel: str, message: UniMessage[Any] | str) -> list[LongTaskTarget]:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
targets = await self.repo.get_channel_targets(channel)
|
||||||
|
for target in targets:
|
||||||
|
# 因为是订阅消息,就不要 At 对方了
|
||||||
|
await target.send_message(message, at=False)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
async def get_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
return await self.repo.get_subscribed_channels(target, pager)
|
||||||
|
|
||||||
|
async def fix_data(self):
|
||||||
|
for cid, cinfo in POSTER_INFO_DATA.items():
|
||||||
|
for alias in cinfo.aliases:
|
||||||
|
await self.repo.merge_channel(alias, cid)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def dep_poster_service():
|
||||||
|
async with local_poster() as repo:
|
||||||
|
yield PosterService(repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(channel: str, message: UniMessage[Any] | str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
return await service.broadcast(channel, message)
|
||||||
|
|
||||||
|
|
||||||
|
DepPosterService = Annotated[PosterService, Depends(dep_poster_service)]
|
||||||
|
|
||||||
@ -6,7 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
import nanoid
|
import nanoid
|
||||||
import nonebot
|
import nonebot
|
||||||
import ptimeparse
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import get_plugin_config, on_message
|
from nonebot import get_plugin_config, on_message
|
||||||
from nonebot.adapters import Event
|
from nonebot.adapters import Event
|
||||||
@ -14,6 +13,7 @@ from nonebot_plugin_alconna import Alconna, Args, Subcommand, UniMessage, UniMsg
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
|
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
|
||||||
|
from konabot.common.ptimeparse import Parser
|
||||||
|
|
||||||
evt = on_message()
|
evt = on_message()
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ async def _(msg: UniMsg, mEvt: Event, target: DepLongTaskTarget):
|
|||||||
|
|
||||||
notify_time, notify_text = segments
|
notify_time, notify_text = segments
|
||||||
try:
|
try:
|
||||||
target_time = ptimeparse.Parser().parse(notify_time)
|
target_time = Parser().parse(notify_time)
|
||||||
logger.info(f"从 {notify_time} 解析出了时间:{target_time}")
|
logger.info(f"从 {notify_time} 解析出了时间:{target_time}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.info(f"无法从 {notify_time} 中解析出时间")
|
logger.info(f"无法从 {notify_time} 中解析出时间")
|
||||||
|
|||||||
2657
poetry.lock
generated
2657
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,6 @@ name = "konabot"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "在 MTTU 内部使用的 bot"
|
description = "在 MTTU 内部使用的 bot"
|
||||||
authors = [{ name = "passthem", email = "Passthem183@gmail.com" }]
|
authors = [{ name = "passthem", email = "Passthem183@gmail.com" }]
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12,<4.0"
|
requires-python = ">=3.12,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nonebot2[all] (>=2.4.3,<3.0.0)",
|
"nonebot2[all] (>=2.4.3,<3.0.0)",
|
||||||
@ -23,25 +22,28 @@ dependencies = [
|
|||||||
"skia-python (>=138.0,<139.0)",
|
"skia-python (>=138.0,<139.0)",
|
||||||
"nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)",
|
"nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)",
|
||||||
"qrcode (>=8.2,<9.0)",
|
"qrcode (>=8.2,<9.0)",
|
||||||
"ptimeparse (>=0.2.1,<0.3.0)",
|
|
||||||
"nanoid (>=2.0.0,<3.0.0)",
|
"nanoid (>=2.0.0,<3.0.0)",
|
||||||
"opencc (>=1.1.9,<2.0.0)",
|
"opencc (>=1.1.9,<2.0.0)",
|
||||||
"playwright (>=1.55.0,<2.0.0)",
|
"playwright (>=1.55.0,<2.0.0)",
|
||||||
"openai (>=2.7.1,<3.0.0)",
|
"openai (>=2.7.1,<3.0.0)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[[tool.poetry.source]]
|
|
||||||
name = "pt-gitea-pypi"
|
|
||||||
url = "https://gitea.service.jazzwhom.top/api/packages/Passthem/pypi/simple/"
|
|
||||||
priority = "supplemental"
|
|
||||||
|
|
||||||
[[tool.poetry.source]]
|
[[tool.poetry.source]]
|
||||||
name = "mirrors"
|
name = "mirrors"
|
||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||||
priority = "primary"
|
priority = "primary"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
|
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"rust-just (>=1.43.0,<2.0.0)"
|
||||||
|
]
|
||||||
|
|||||||
@ -8,6 +8,8 @@ base = Path(__file__).parent.parent.absolute()
|
|||||||
def filter(change: Change, path: str) -> bool:
|
def filter(change: Change, path: str) -> bool:
|
||||||
if "__pycache__" in path:
|
if "__pycache__" in path:
|
||||||
return False
|
return False
|
||||||
if Path(path).absolute().is_relative_to(base / "data"):
|
if Path(path).absolute().is_relative_to((base / "data").absolute()):
|
||||||
|
return False
|
||||||
|
if Path(path).absolute().is_relative_to((base / ".git").absolute()):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user