Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eff25435e3 | |||
| df28fad697 | |||
| 561f6981aa | |||
| 2632215af9 | |||
| bfde559892 | |||
| 857f8c5955 | |||
| 500053e630 | |||
| 30cfb4cadd | |||
| e2f99af73b | |||
| e09de9eeb6 | |||
| 4a3b49ce79 | |||
| 03900f4416 | |||
| 62f4195e46 | |||
| 751297e3bc | |||
| b450998f3f |
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
|
||||||
|
|||||||
@ -76,7 +76,7 @@ code .
|
|||||||
使用命令行手动启动 Bot:
|
使用命令行手动启动 Bot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
poetry run just watch
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
如果你不希望自动重载,只是想运行 Bot,可以直接运行:
|
||||||
|
|||||||
4
justfile
Normal file
4
justfile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
watch:
|
||||||
|
poetry run watchfiles bot.main . --filter scripts.watch_filter.filter
|
||||||
|
|
||||||
|
|
||||||
76
konabot/common/pager.py
Normal file
76
konabot/common/pager.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from math import ceil
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagerQuery:
|
||||||
|
page_index: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
def apply[T](self, ls: list[T]) -> "PagerResult[T]":
|
||||||
|
if self.page_size <= 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="每页元素数量应该大于 0",
|
||||||
|
data=[],
|
||||||
|
page_count=-1,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
page_count = ceil(len(ls) / self.page_size)
|
||||||
|
if self.page_index <= 0 or self.page_size <= 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="页数必须大于 0",
|
||||||
|
data=[],
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
data = ls[(self.page_index - 1) * self.page_size: self.page_index * self.page_size]
|
||||||
|
if len(data) > 0:
|
||||||
|
return PagerResult(
|
||||||
|
success=True,
|
||||||
|
message="",
|
||||||
|
data=data,
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
return PagerResult(
|
||||||
|
success=False,
|
||||||
|
message="指定的页数超过最大页数",
|
||||||
|
data=data,
|
||||||
|
page_count=page_count,
|
||||||
|
query=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PagerResult[T]:
|
||||||
|
data: list[T]
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
page_count: int
|
||||||
|
query: PagerQuery
|
||||||
|
|
||||||
|
def to_unimessage(
|
||||||
|
self,
|
||||||
|
formatter: Callable[[T], str | UniMessage[Any]] = str,
|
||||||
|
title: str = '查询结果',
|
||||||
|
list_indicator: str = '- ',
|
||||||
|
) -> UniMessage[Any]:
|
||||||
|
msg = UniMessage.text(f'===== {title} =====\n\n')
|
||||||
|
|
||||||
|
if not self.success:
|
||||||
|
msg = msg.text(f'⚠️ {self.message}\n')
|
||||||
|
else:
|
||||||
|
for obj in self.data:
|
||||||
|
msg = msg.text(list_indicator)
|
||||||
|
msg += formatter(obj)
|
||||||
|
msg += '\n'
|
||||||
|
|
||||||
|
msg = msg.text(f'\n===== 第 {self.query.page_index} 页,共 {self.page_count} 页 =====')
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
653
konabot/common/ptimeparse/__init__.py
Normal file
653
konabot/common/ptimeparse/__init__.py
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import re
|
||||||
|
import datetime
|
||||||
|
from typing import Tuple, Optional, Dict, Any
|
||||||
|
|
||||||
|
from .err import MultipleSpecificationException, TokenUnhandledException
|
||||||
|
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
def __init__(self, now: Optional[datetime.datetime] = None):
|
||||||
|
self.now = now or datetime.datetime.now()
|
||||||
|
|
||||||
|
def digest_chinese_number(self, text: str) -> Tuple[str, int]:
|
||||||
|
if not text:
|
||||||
|
return text, 0
|
||||||
|
# Handle "两" at start
|
||||||
|
if text.startswith("两"):
|
||||||
|
next_char = text[1] if len(text) > 1 else ''
|
||||||
|
if not next_char or next_char in "十百千万亿":
|
||||||
|
return text[1:], 2
|
||||||
|
s = "零一二三四五六七八九"
|
||||||
|
digits = {c: i for i, c in enumerate(s)}
|
||||||
|
i = 0
|
||||||
|
while i < len(text) and text[i] in s + "十百千万亿":
|
||||||
|
i += 1
|
||||||
|
if i == 0:
|
||||||
|
return text, 0
|
||||||
|
num_str = text[:i]
|
||||||
|
rest = text[i:]
|
||||||
|
|
||||||
|
def parse(s):
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
if s == "零":
|
||||||
|
return 0
|
||||||
|
if "亿" in s:
|
||||||
|
a, b = s.split("亿", 1)
|
||||||
|
return parse(a) * 100000000 + parse(b)
|
||||||
|
if "万" in s:
|
||||||
|
a, b = s.split("万", 1)
|
||||||
|
return parse(a) * 10000 + parse(b)
|
||||||
|
n = 0
|
||||||
|
t = 0
|
||||||
|
for c in s:
|
||||||
|
if c == "零":
|
||||||
|
continue
|
||||||
|
if c in digits:
|
||||||
|
t = digits[c]
|
||||||
|
elif c == "十":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 10
|
||||||
|
t = 0
|
||||||
|
elif c == "百":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 100
|
||||||
|
t = 0
|
||||||
|
elif c == "千":
|
||||||
|
if t == 0:
|
||||||
|
t = 1
|
||||||
|
n += t * 1000
|
||||||
|
t = 0
|
||||||
|
n += t
|
||||||
|
return n
|
||||||
|
|
||||||
|
return rest, parse(num_str)
|
||||||
|
|
||||||
|
def parse(self, text: str) -> datetime.datetime:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
raise TokenUnhandledException("Empty input")
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"date": None,
|
||||||
|
"time": None,
|
||||||
|
"relative_delta": None,
|
||||||
|
"am_pm": None,
|
||||||
|
"period_word": None,
|
||||||
|
"has_time": False,
|
||||||
|
"has_date": False,
|
||||||
|
"ambiguous_hour": False,
|
||||||
|
"is_24hour": False,
|
||||||
|
"has_relative_date": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = self._parse_all(text, ctx)
|
||||||
|
if rest.strip():
|
||||||
|
raise TokenUnhandledException(f"Unparsed tokens: {rest.strip()}")
|
||||||
|
|
||||||
|
return self._apply_context(ctx)
|
||||||
|
|
||||||
|
def _parse_all(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
rest = text.lstrip()
|
||||||
|
while True:
|
||||||
|
for parser in [
|
||||||
|
self._parse_absolute_date,
|
||||||
|
self._parse_relative_date,
|
||||||
|
self._parse_relative_time,
|
||||||
|
self._parse_period,
|
||||||
|
self._parse_time,
|
||||||
|
]:
|
||||||
|
new_rest = parser(rest, ctx)
|
||||||
|
if new_rest != rest:
|
||||||
|
rest = new_rest.lstrip()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return rest
|
||||||
|
|
||||||
|
def _add_delta(self, ctx, delta):
|
||||||
|
if ctx["relative_delta"] is None:
|
||||||
|
ctx["relative_delta"] = delta
|
||||||
|
else:
|
||||||
|
ctx["relative_delta"] += delta
|
||||||
|
|
||||||
|
def _parse_absolute_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d, h, minute = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["time"] = datetime.time(h, minute)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["is_24hour"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})/(\d{1,2})/(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{4})年(\d{1,2})月(\d{1,2})[日号]", text)
|
||||||
|
if m:
|
||||||
|
y, mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(y, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(\d{1,2})月(\d{1,2})[日号]", text)
|
||||||
|
if m:
|
||||||
|
mth, d = map(int, m.groups())
|
||||||
|
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(.{1,3})月(.{1,3})[日号]", text)
|
||||||
|
if m:
|
||||||
|
m_str, d_str = m.groups()
|
||||||
|
_, mth = self.digest_chinese_number(m_str)
|
||||||
|
_, d = self.digest_chinese_number(d_str)
|
||||||
|
if mth == 0:
|
||||||
|
mth = 1
|
||||||
|
if d == 0:
|
||||||
|
d = 1
|
||||||
|
ctx["date"] = datetime.date(self.now.year, mth, d)
|
||||||
|
ctx["has_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_relative_date(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# Handle "今天", "今晚", "今早", etc.
|
||||||
|
today_variants = [
|
||||||
|
("今晚上", "PM"),
|
||||||
|
("今晚", "PM"),
|
||||||
|
("今早", "AM"),
|
||||||
|
("今天早上", "AM"),
|
||||||
|
("今天早晨", "AM"),
|
||||||
|
("今天上午", "AM"),
|
||||||
|
("今天下午", "PM"),
|
||||||
|
("今天晚上", "PM"),
|
||||||
|
("今天", None),
|
||||||
|
]
|
||||||
|
for variant, period in today_variants:
|
||||||
|
if text.startswith(variant):
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=0))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
rest = text[len(variant):]
|
||||||
|
if period is not None and ctx["am_pm"] is None:
|
||||||
|
ctx["am_pm"] = period
|
||||||
|
ctx["period_word"] = variant
|
||||||
|
return rest
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"明天": 1,
|
||||||
|
"后天": 2,
|
||||||
|
"大后天": 3,
|
||||||
|
"昨天": -1,
|
||||||
|
"前天": -2,
|
||||||
|
"大前天": -3,
|
||||||
|
}
|
||||||
|
for word, days in mapping.items():
|
||||||
|
if text.startswith(word):
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[len(word):]
|
||||||
|
m = re.match(r"^(\d+|[零一二三四五六七八九十两]+)天(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
n = int(num_str)
|
||||||
|
else:
|
||||||
|
_, n = self.digest_chinese_number(num_str)
|
||||||
|
days = n if direction in ("后", "以后", "之后") else -n
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=days))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
m = re.match(r"^(本|上|下)周([一二三四五六日])", text)
|
||||||
|
if m:
|
||||||
|
scope, day = m.groups()
|
||||||
|
weekday_map = {"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6}
|
||||||
|
target = weekday_map[day]
|
||||||
|
current = self.now.weekday()
|
||||||
|
if scope == "本":
|
||||||
|
delta = target - current
|
||||||
|
elif scope == "上":
|
||||||
|
delta = target - current - 7
|
||||||
|
else:
|
||||||
|
delta = target - current + 7
|
||||||
|
self._add_delta(ctx, datetime.timedelta(days=delta))
|
||||||
|
ctx["has_relative_date"] = True
|
||||||
|
return text[m.end():]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_period(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
period_mapping = {
|
||||||
|
"上午": "AM",
|
||||||
|
"早晨": "AM",
|
||||||
|
"早上": "AM",
|
||||||
|
"早": "AM",
|
||||||
|
"中午": "PM",
|
||||||
|
"下午": "PM",
|
||||||
|
"晚上": "PM",
|
||||||
|
"晚": "PM",
|
||||||
|
"凌晨": "AM",
|
||||||
|
}
|
||||||
|
for word, tag in period_mapping.items():
|
||||||
|
if text.startswith(word):
|
||||||
|
if ctx["am_pm"] is not None:
|
||||||
|
raise MultipleSpecificationException("Multiple periods")
|
||||||
|
ctx["am_pm"] = tag
|
||||||
|
ctx["period_word"] = word
|
||||||
|
return text[len(word):]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _parse_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
if ctx["has_time"]:
|
||||||
|
return text
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# 1. H:MM pattern
|
||||||
|
m = re.match(r"^(\d{1,2}):(\d{2})", text)
|
||||||
|
if m:
|
||||||
|
h, minute = int(m.group(1)), int(m.group(2))
|
||||||
|
if 0 <= h <= 23 and 0 <= minute <= 59:
|
||||||
|
ctx["time"] = datetime.time(h, minute)
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["ambiguous_hour"] = 1 <= h <= 12
|
||||||
|
ctx["is_24hour"] = h > 12 or h == 0
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# 2. Parse hour part
|
||||||
|
hour = None
|
||||||
|
rest_after_hour = text
|
||||||
|
is_24hour_format = False
|
||||||
|
|
||||||
|
# Try Chinese number + 点/时
|
||||||
|
temp_rest, num = self.digest_chinese_number(text)
|
||||||
|
if num >= 0:
|
||||||
|
temp_rest_stripped = temp_rest.lstrip()
|
||||||
|
if temp_rest_stripped.startswith("点"):
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = temp_rest_stripped[1:]
|
||||||
|
elif temp_rest_stripped.startswith("时"):
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = True
|
||||||
|
rest_after_hour = temp_rest_stripped[1:]
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
m = re.match(r"^(\d{1,2})\s*([点时])", text)
|
||||||
|
if m:
|
||||||
|
hour = int(m.group(1))
|
||||||
|
is_24hour_format = m.group(2) == "时"
|
||||||
|
rest_after_hour = text[m.end():]
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
if ctx.get("am_pm") is not None:
|
||||||
|
temp_rest, num = self.digest_chinese_number(text)
|
||||||
|
if 0 <= num <= 23:
|
||||||
|
hour = num
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = temp_rest.lstrip()
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})", text)
|
||||||
|
if m:
|
||||||
|
h_val = int(m.group(1))
|
||||||
|
if 0 <= h_val <= 23:
|
||||||
|
hour = h_val
|
||||||
|
is_24hour_format = False
|
||||||
|
rest_after_hour = text[m.end():].lstrip()
|
||||||
|
|
||||||
|
if hour is None:
|
||||||
|
return text
|
||||||
|
|
||||||
|
if not (0 <= hour <= 23):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Parse minutes
|
||||||
|
rest = rest_after_hour.lstrip()
|
||||||
|
minute = 0
|
||||||
|
minute_spec_count = 0
|
||||||
|
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
has_zheng = False
|
||||||
|
if rest.startswith("整"):
|
||||||
|
has_zheng = True
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("半"):
|
||||||
|
minute = 30
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
if rest.startswith("整"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("一刻"):
|
||||||
|
minute = 15
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[2:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
if rest.startswith("过一刻"):
|
||||||
|
minute = 15
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[3:].lstrip()
|
||||||
|
if rest.startswith("钟"):
|
||||||
|
rest = rest[1:].lstrip()
|
||||||
|
|
||||||
|
m = re.match(r"^(\d+|[零一二三四五六七八九十]+)分", rest)
|
||||||
|
if m:
|
||||||
|
minute_spec_count += 1
|
||||||
|
m_str = m.group(1)
|
||||||
|
if m_str.isdigit():
|
||||||
|
minute = int(m_str)
|
||||||
|
else:
|
||||||
|
_, minute = self.digest_chinese_number(m_str)
|
||||||
|
rest = rest[m.end():].lstrip()
|
||||||
|
|
||||||
|
if minute_spec_count == 0:
|
||||||
|
temp_rest, num = self.digest_chinese_number(rest)
|
||||||
|
if num > 0 and num <= 59:
|
||||||
|
minute = num
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = temp_rest.lstrip()
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})", rest)
|
||||||
|
if m:
|
||||||
|
m_val = int(m.group(1))
|
||||||
|
if 0 <= m_val <= 59:
|
||||||
|
minute = m_val
|
||||||
|
minute_spec_count += 1
|
||||||
|
rest = rest[m.end():].lstrip()
|
||||||
|
|
||||||
|
if has_zheng and minute_spec_count == 0:
|
||||||
|
minute_spec_count = 1
|
||||||
|
|
||||||
|
if minute_spec_count > 1:
|
||||||
|
raise MultipleSpecificationException("Multiple minute specifications")
|
||||||
|
|
||||||
|
if not (0 <= minute <= 59):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Hours 13-23 are always 24-hour, even with "点"
|
||||||
|
if hour >= 13:
|
||||||
|
is_24hour_format = True
|
||||||
|
|
||||||
|
ctx["time"] = datetime.time(hour, minute)
|
||||||
|
ctx["has_time"] = True
|
||||||
|
ctx["ambiguous_hour"] = 1 <= hour <= 12 and not is_24hour_format
|
||||||
|
ctx["is_24hour"] = is_24hour_format
|
||||||
|
|
||||||
|
return rest
|
||||||
|
|
||||||
|
def _parse_relative_time(self, text: str, ctx: Dict[str, Any]) -> str:
|
||||||
|
text = text.lstrip()
|
||||||
|
|
||||||
|
# 半小时
|
||||||
|
m = re.match(r"^(半)(?:个)?小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
direction = m.group(2)
|
||||||
|
hours = 0.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X个半
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)个半(?:小时?)?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
base_hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, base_hours = self.digest_chinese_number(num_str)
|
||||||
|
if base_hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if base_hours <= 0:
|
||||||
|
return text
|
||||||
|
hours = base_hours + 0.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# 一个半
|
||||||
|
m = re.match(r"^(一个半)小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
direction = m.group(2)
|
||||||
|
hours = 1.5
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X小时
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, hours = self.digest_chinese_number(num_str)
|
||||||
|
if hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if hours <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction in ("后", "以后", "之后") else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)(?:个)?小时(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
hours = int(num_str)
|
||||||
|
else:
|
||||||
|
_, hours = self.digest_chinese_number(num_str)
|
||||||
|
if hours == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if hours <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
hours=hours if direction == "后" else -hours
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# X分钟
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction in ("后", "以后", "之后") else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分钟?(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction == "后" else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)分(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
minutes = int(num_str)
|
||||||
|
else:
|
||||||
|
_, minutes = self.digest_chinese_number(num_str)
|
||||||
|
if minutes == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if minutes <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
minutes=minutes if direction == "后" else -minutes
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
# === 秒级支持 ===
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前|以后|之后)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
seconds = int(num_str)
|
||||||
|
else:
|
||||||
|
_, seconds = self.digest_chinese_number(num_str)
|
||||||
|
if seconds == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if seconds <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
seconds=seconds if direction in ("后", "以后", "之后") else -seconds
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
m = re.match(r"^([0-9零一二三四五六七八九十两]+)秒(后|前)", text)
|
||||||
|
if m:
|
||||||
|
num_str, direction = m.groups()
|
||||||
|
if num_str.isdigit():
|
||||||
|
seconds = int(num_str)
|
||||||
|
else:
|
||||||
|
_, seconds = self.digest_chinese_number(num_str)
|
||||||
|
if seconds == 0 and num_str != "零":
|
||||||
|
return text
|
||||||
|
if seconds <= 0:
|
||||||
|
return text
|
||||||
|
delta = datetime.timedelta(
|
||||||
|
seconds=seconds if direction == "后" else -seconds
|
||||||
|
)
|
||||||
|
self._add_delta(ctx, delta)
|
||||||
|
return text[m.end():]
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _apply_context(self, ctx: Dict[str, Any]) -> datetime.datetime:
|
||||||
|
result = self.now
|
||||||
|
has_date = ctx["has_date"]
|
||||||
|
has_time = ctx["has_time"]
|
||||||
|
has_delta = ctx["relative_delta"] is not None
|
||||||
|
has_relative_date = ctx["has_relative_date"]
|
||||||
|
|
||||||
|
if has_delta:
|
||||||
|
result = result + ctx["relative_delta"]
|
||||||
|
|
||||||
|
if has_date:
|
||||||
|
result = result.replace(
|
||||||
|
year=ctx["date"].year,
|
||||||
|
month=ctx["date"].month,
|
||||||
|
day=ctx["date"].day,
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_time:
|
||||||
|
h = ctx["time"].hour
|
||||||
|
m = ctx["time"].minute
|
||||||
|
|
||||||
|
if ctx["is_24hour"]:
|
||||||
|
# "10 时" → 10:00, no conversion
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif ctx["am_pm"] == "AM":
|
||||||
|
if h == 12:
|
||||||
|
h = 0
|
||||||
|
|
||||||
|
elif ctx["am_pm"] == "PM":
|
||||||
|
if h == 12:
|
||||||
|
if ctx.get("period_word") in ("晚上", "晚"):
|
||||||
|
h = 0
|
||||||
|
result += datetime.timedelta(days=1)
|
||||||
|
else:
|
||||||
|
h = 12
|
||||||
|
elif 1 <= h <= 11:
|
||||||
|
h += 12
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No period and not 24-hour (i.e., "点" format)
|
||||||
|
if ctx["has_relative_date"]:
|
||||||
|
# "明天五点" → 05:00 AM
|
||||||
|
if h == 12:
|
||||||
|
h = 0
|
||||||
|
# keep h as AM hour (1-11 unchanged)
|
||||||
|
else:
|
||||||
|
# Infer from current time
|
||||||
|
am_hour = 0 if h == 12 else h
|
||||||
|
candidate_am = result.replace(hour=am_hour, minute=m, second=0, microsecond=0)
|
||||||
|
if candidate_am < self.now:
|
||||||
|
# AM time is in the past, so use PM
|
||||||
|
if h == 12:
|
||||||
|
h = 12
|
||||||
|
else:
|
||||||
|
h += 12
|
||||||
|
# else: keep as AM (h unchanged)
|
||||||
|
|
||||||
|
if h > 23:
|
||||||
|
h = h % 24
|
||||||
|
|
||||||
|
result = result.replace(hour=h, minute=m, second=0, microsecond=0)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if has_date or (has_relative_date and not has_time):
|
||||||
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text: str) -> datetime.datetime:
|
||||||
|
return Parser().parse(text)
|
||||||
11
konabot/common/ptimeparse/err.py
Normal file
11
konabot/common/ptimeparse/err.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class PTimeParseException(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
class TokenUnhandledException(PTimeParseException):
|
||||||
|
...
|
||||||
|
|
||||||
|
class MultipleSpecificationException(PTimeParseException):
|
||||||
|
...
|
||||||
|
|
||||||
|
class OutOfRangeSpecificationException(PTimeParseException):
|
||||||
|
...
|
||||||
@ -5,6 +5,7 @@ from pydantic import BaseModel
|
|||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
module_web_render_weburl: str = "localhost:5173"
|
module_web_render_weburl: str = "localhost:5173"
|
||||||
module_web_render_instance: str = ""
|
module_web_render_instance: str = ""
|
||||||
|
module_web_render_playwright_ws: str = ""
|
||||||
|
|
||||||
def get_instance_baseurl(self):
|
def get_instance_baseurl(self):
|
||||||
if self.module_web_render_instance:
|
if self.module_web_render_instance:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
import queue
|
import queue
|
||||||
from typing import Any, Callable, Coroutine
|
from typing import Any, Callable, Coroutine, Generic, TypeVar
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from playwright.async_api import (
|
from playwright.async_api import (
|
||||||
Page,
|
Page,
|
||||||
@ -8,9 +9,14 @@ from playwright.async_api import (
|
|||||||
async_playwright,
|
async_playwright,
|
||||||
Browser,
|
Browser,
|
||||||
BrowserContext,
|
BrowserContext,
|
||||||
|
Error as PlaywrightError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .config import web_render_config
|
||||||
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
TFunction = Callable[[T], Coroutine[Any, Any, Any]]
|
||||||
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
PageFunction = Callable[[Page], Coroutine[Any, Any, Any]]
|
||||||
|
|
||||||
|
|
||||||
@ -22,23 +28,17 @@ class WebRenderer:
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_browser_instance(cls) -> "WebRendererInstance":
|
async def get_browser_instance(cls) -> "WebRendererInstance":
|
||||||
if cls.browser_pool.empty():
|
if cls.browser_pool.empty():
|
||||||
instance = await WebRendererInstance.create()
|
if web_render_config.module_web_render_playwright_ws:
|
||||||
|
instance = await RemotePlaywrightInstance.create(
|
||||||
|
web_render_config.module_web_render_playwright_ws
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instance = await LocalPlaywrightInstance.create()
|
||||||
cls.browser_pool.put(instance)
|
cls.browser_pool.put(instance)
|
||||||
instance = cls.browser_pool.get()
|
instance = cls.browser_pool.get()
|
||||||
cls.browser_pool.put(instance)
|
cls.browser_pool.put(instance)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_browser_context(cls) -> BrowserContext:
|
|
||||||
instance = await cls.get_browser_instance()
|
|
||||||
if id(instance) not in cls.context_pool:
|
|
||||||
context = await instance.browser.new_context()
|
|
||||||
cls.context_pool[id(instance)] = context
|
|
||||||
logger.debug(
|
|
||||||
f"Created new persistent browser context for WebRendererInstance {id(instance)}"
|
|
||||||
)
|
|
||||||
return cls.context_pool[id(instance)]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def render(
|
async def render(
|
||||||
cls,
|
cls,
|
||||||
@ -67,49 +67,6 @@ class WebRenderer:
|
|||||||
url, target, params=params, other_function=other_function, timeout=timeout
|
url, target, params=params, other_function=other_function, timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def render_persistent_page(
|
|
||||||
cls,
|
|
||||||
page_id: str,
|
|
||||||
url: str,
|
|
||||||
target: str,
|
|
||||||
params: dict = {},
|
|
||||||
other_function: PageFunction | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> bytes:
|
|
||||||
"""
|
|
||||||
使用长期挂载的页面访问指定URL并返回截图
|
|
||||||
|
|
||||||
:param page_id: 页面唯一标识符
|
|
||||||
:param url: 目标URL
|
|
||||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
|
||||||
:param timeout: 页面加载超时时间,单位秒
|
|
||||||
:param params: URL键值对参数
|
|
||||||
:param other_function: 其他自定义操作函数,接受page参数
|
|
||||||
:return: 截图的字节数据
|
|
||||||
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"Requesting persistent render for page_id {page_id} at {url} targeting {target} with timeout {timeout}"
|
|
||||||
)
|
|
||||||
instance = await cls.get_browser_instance()
|
|
||||||
if page_id not in cls.page_pool:
|
|
||||||
context = await cls.get_browser_context()
|
|
||||||
page = await context.new_page()
|
|
||||||
cls.page_pool[page_id] = page
|
|
||||||
logger.debug(
|
|
||||||
f"Created new persistent page for page_id {page_id} using WebRendererInstance {id(instance)}"
|
|
||||||
)
|
|
||||||
page = cls.page_pool[page_id]
|
|
||||||
return await instance.render_with_page(
|
|
||||||
page,
|
|
||||||
url,
|
|
||||||
target,
|
|
||||||
params=params,
|
|
||||||
other_function=other_function,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def render_file(
|
async def render_file(
|
||||||
cls,
|
cls,
|
||||||
@ -142,6 +99,75 @@ class WebRenderer:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def render_with_persistent_page(
|
||||||
|
cls,
|
||||||
|
page_id: str,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
params: dict = {},
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
使用长期挂载的页面进行渲染
|
||||||
|
|
||||||
|
:param page_id: 页面唯一标识符
|
||||||
|
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||||
|
:param timeout: 页面加载超时时间,单位秒
|
||||||
|
:param params: URL键值对参数
|
||||||
|
:param other_function: 其他自定义操作函数,接受page参数
|
||||||
|
:return: 截图的字节数据
|
||||||
|
|
||||||
|
"""
|
||||||
|
instance = await cls.get_browser_instance()
|
||||||
|
logger.debug(
|
||||||
|
f"Using WebRendererInstance {id(instance)} to render with persistent page {page_id} targeting {target}"
|
||||||
|
)
|
||||||
|
return await instance.render_with_persistent_page(
|
||||||
|
page_id,
|
||||||
|
url,
|
||||||
|
target,
|
||||||
|
params=params,
|
||||||
|
other_function=other_function,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_persistent_page(cls, page_id: str, url: str) -> Page:
|
||||||
|
"""
|
||||||
|
获取长期挂载的页面,如果不存在则创建一个新的页面并存储
|
||||||
|
"""
|
||||||
|
if page_id in cls.page_pool:
|
||||||
|
return cls.page_pool[page_id]
|
||||||
|
|
||||||
|
async def on_console(msg: ConsoleMessage):
|
||||||
|
logger.debug(f"WEB CONSOLE {msg.text}")
|
||||||
|
|
||||||
|
instance = await cls.get_browser_instance()
|
||||||
|
if isinstance(instance, RemotePlaywrightInstance):
|
||||||
|
context = await instance.browser.new_context()
|
||||||
|
page = await context.new_page()
|
||||||
|
await page.goto(url)
|
||||||
|
cls.page_pool[page_id] = page
|
||||||
|
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||||
|
|
||||||
|
page.on('console', on_console)
|
||||||
|
|
||||||
|
return page
|
||||||
|
elif isinstance(instance, LocalPlaywrightInstance):
|
||||||
|
context = await instance.browser.new_context()
|
||||||
|
page = await context.new_page()
|
||||||
|
await page.goto(url)
|
||||||
|
cls.page_pool[page_id] = page
|
||||||
|
logger.debug(f"Created new persistent page for page_id {page_id}, navigated to {url}")
|
||||||
|
|
||||||
|
page.on('console', on_console)
|
||||||
|
|
||||||
|
return page
|
||||||
|
else:
|
||||||
|
raise NotImplementedError("Unsupported WebRendererInstance type")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def close_persistent_page(cls, page_id: str) -> None:
|
async def close_persistent_page(cls, page_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
@ -156,38 +182,56 @@ class WebRenderer:
|
|||||||
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
|
logger.debug(f"Closed and removed persistent page for page_id {page_id}")
|
||||||
|
|
||||||
|
|
||||||
class WebRendererInstance:
|
class WebRendererInstance(ABC, Generic[T]):
|
||||||
def __init__(self):
|
@abstractmethod
|
||||||
self._playwright: Playwright | None = None
|
async def render(
|
||||||
self._browser: Browser | None = None
|
self,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
index: int = 0,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
other_function: TFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def render_file(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
target: str,
|
||||||
|
index: int = 0,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def render_with_persistent_page(
|
||||||
|
self,
|
||||||
|
page_id: str,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
params: dict = {},
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PlaywrightInstance(WebRendererInstance[Page]):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playwright(self) -> Playwright:
|
@abstractmethod
|
||||||
assert self._playwright is not None
|
def browser(self) -> Browser: ...
|
||||||
return self._playwright
|
|
||||||
|
|
||||||
@property
|
|
||||||
def browser(self) -> Browser:
|
|
||||||
assert self._browser is not None
|
|
||||||
return self._browser
|
|
||||||
|
|
||||||
async def init(self):
|
|
||||||
self._playwright = await async_playwright().start()
|
|
||||||
self._browser = await self.playwright.chromium.launch(headless=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def create(cls) -> "WebRendererInstance":
|
|
||||||
instance = cls()
|
|
||||||
await instance.init()
|
|
||||||
return instance
|
|
||||||
|
|
||||||
async def render(
|
async def render(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
target: str,
|
target: str,
|
||||||
index: int = 0,
|
index: int = 0,
|
||||||
params: dict = {},
|
params: dict[str, Any] | None = None,
|
||||||
other_function: PageFunction | None = None,
|
other_function: PageFunction | None = None,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
@ -207,42 +251,41 @@ class WebRendererInstance:
|
|||||||
context = await self.browser.new_context()
|
context = await self.browser.new_context()
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
screenshot = await self.inner_render(
|
screenshot = await self.inner_render(
|
||||||
page, url, target, index, params, other_function, timeout
|
page, url, target, index, params or {}, other_function, timeout
|
||||||
)
|
)
|
||||||
await page.close()
|
await page.close()
|
||||||
await context.close()
|
await context.close()
|
||||||
return screenshot
|
return screenshot
|
||||||
|
|
||||||
async def render_with_page(
|
|
||||||
self,
|
|
||||||
page: Page,
|
|
||||||
url: str,
|
|
||||||
target: str,
|
|
||||||
index: int = 0,
|
|
||||||
params: dict = {},
|
|
||||||
other_function: PageFunction | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> bytes:
|
|
||||||
async with self.lock:
|
|
||||||
screenshot = await self.inner_render(
|
|
||||||
page, url, target, index, params, other_function, timeout
|
|
||||||
)
|
|
||||||
return screenshot
|
|
||||||
|
|
||||||
async def render_file(
|
async def render_file(
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
target: str,
|
target: str,
|
||||||
index: int = 0,
|
index: int = 0,
|
||||||
params: dict = {},
|
params: dict[str, Any] | None = None,
|
||||||
other_function: PageFunction | None = None,
|
other_function: PageFunction | None = None,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
file_path = "file:///" + str(file_path).replace("\\", "/")
|
file_path = "file:///" + str(file_path).replace("\\", "/")
|
||||||
return await self.render(
|
return await self.render(
|
||||||
file_path, target, index, params, other_function, timeout
|
file_path, target, index, params or {}, other_function, timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def render_with_persistent_page(
|
||||||
|
self,
|
||||||
|
page_id: str,
|
||||||
|
url: str,
|
||||||
|
target: str,
|
||||||
|
params: dict = {},
|
||||||
|
other_function: PageFunction | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> bytes:
|
||||||
|
page = await WebRenderer.get_persistent_page(page_id, url)
|
||||||
|
screenshot = await self.inner_render(
|
||||||
|
page, url, target, 0, params, other_function, timeout
|
||||||
|
)
|
||||||
|
return screenshot
|
||||||
|
|
||||||
async def inner_render(
|
async def inner_render(
|
||||||
self,
|
self,
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -276,6 +319,85 @@ class WebRendererInstance:
|
|||||||
logger.debug("Screenshot taken successfully")
|
logger.debug("Screenshot taken successfully")
|
||||||
return screenshot
|
return screenshot
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPlaywrightInstance(PlaywrightInstance):
|
||||||
|
def __init__(self):
|
||||||
|
self._playwright: Playwright | None = None
|
||||||
|
self._browser: Browser | None = None
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playwright(self) -> Playwright:
|
||||||
|
assert self._playwright is not None
|
||||||
|
return self._playwright
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browser(self) -> Browser:
|
||||||
|
assert self._browser is not None
|
||||||
|
return self._browser
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
self._playwright = await async_playwright().start()
|
||||||
|
self._browser = await self.playwright.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls) -> "WebRendererInstance":
|
||||||
|
instance = cls()
|
||||||
|
await instance.init()
|
||||||
|
return instance
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
await self.browser.close()
|
await self.browser.close()
|
||||||
await self.playwright.stop()
|
await self.playwright.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class RemotePlaywrightInstance(PlaywrightInstance):
|
||||||
|
def __init__(self, ws_endpoint: str) -> None:
|
||||||
|
self._playwright: Playwright | None = None
|
||||||
|
self._browser: Browser | None = None
|
||||||
|
self._ws_endpoint = ws_endpoint
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playwright(self) -> Playwright:
|
||||||
|
assert self._playwright is not None, "Playwright must be initialized by calling init()."
|
||||||
|
return self._playwright
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browser(self) -> Browser:
|
||||||
|
assert self._browser is not None, "Browser must be connected by calling init()."
|
||||||
|
return self._browser
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
logger.info(f"尝试连接远程 Playwright 服务器: {self._ws_endpoint}")
|
||||||
|
self._playwright = await async_playwright().start()
|
||||||
|
try:
|
||||||
|
self._browser = await self.playwright.chromium.connect(
|
||||||
|
self._ws_endpoint
|
||||||
|
)
|
||||||
|
logger.info("成功连接到远程 Playwright 服务器。")
|
||||||
|
except PlaywrightError as e:
|
||||||
|
await self.playwright.stop()
|
||||||
|
raise ConnectionError(
|
||||||
|
f"无法连接到远程 Playwright 服务器 ({self._ws_endpoint}):{e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, ws_endpoint: str) -> "RemotePlaywrightInstance":
|
||||||
|
"""
|
||||||
|
创建并初始化远程 Playwright 实例的工厂方法。
|
||||||
|
"""
|
||||||
|
instance = cls(ws_endpoint)
|
||||||
|
await instance.init()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""
|
||||||
|
断开与远程浏览器的连接并停止本地 Playwright 实例。
|
||||||
|
"""
|
||||||
|
if self._browser:
|
||||||
|
await self.browser.close()
|
||||||
|
if self._playwright:
|
||||||
|
await self.playwright.stop()
|
||||||
|
print("已断开远程连接,本地 Playwright 实例已停止。")
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
关于「中间答案」或者「提示」:
|
# 关于「中间答案」或者「提示」
|
||||||
|
|
||||||
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
|
在 KonaPH 中,当有人发送「提交答案 答案」时,会检查答案是否符合你设置的中间答案的 pattern。这个 pattern 可以有两种方式:
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
关于罗文和洛温:
|
# 关于罗文和洛温
|
||||||
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。
|
|
||||||
|
AdoreLowen 希望和洛温阿特金森区分,所以最好就不要叫他洛温了!此方 BOT 会在一些群提醒叫错了的人。
|
||||||
|
|||||||
@ -1,45 +1,48 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
is_admin - 用于判断当前事件是否来自管理员的内部权限校验函数
|
|
||||||
|
|
||||||
格式
|
`is_admin` - 用于判断当前事件是否来自管理员的内部权限校验函数
|
||||||
from konabot.common.nb.is_admin import is_admin
|
|
||||||
from nonebot import on
|
|
||||||
from nonebot.adapters import Event
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
@on().handle()
|
# 格式
|
||||||
async def _(event: Event):
|
|
||||||
if is_admin(event):
|
|
||||||
logger.info("管理员发送了消息")
|
|
||||||
|
|
||||||
说明
|
```python
|
||||||
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
from konabot.common.nb.is_admin import is_admin
|
||||||
|
from nonebot import on
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
@on().handle()
|
||||||
|
async def _(event: Event):
|
||||||
|
if is_admin(event):
|
||||||
|
logger.info("管理员发送了消息")
|
||||||
|
```
|
||||||
|
|
||||||
|
# 说明
|
||||||
|
|
||||||
|
is_admin 是 Bot 内部用于权限控制的核心函数,根据事件来源(QQ、Discord、控制台)及插件配置,判断触发事件的用户或群组是否具有管理员权限。
|
||||||
|
|
||||||
支持的适配器与判定逻辑:
|
支持的适配器与判定逻辑:
|
||||||
• OneBot V11(QQ)
|
|
||||||
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
|
||||||
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
|
||||||
|
|
||||||
• Discord
|
- OneBot V11(QQ)
|
||||||
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
- 若用户 ID 在配置项 admin_qq_account 中,则视为管理员
|
||||||
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
- 若为群聊消息,且群 ID 在配置项 admin_qq_group 中,则视为管理员
|
||||||
|
- Discord
|
||||||
|
- 若频道 ID 在配置项 admin_discord_channel 中,则视为管理员
|
||||||
|
- 若用户 ID 在配置项 admin_discord_account 中,则视为管理员
|
||||||
|
- Console(控制台)
|
||||||
|
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
||||||
|
|
||||||
• Console(控制台)
|
# 配置项(位于插件配置中)
|
||||||
- 所有控制台输入均默认视为管理员操作,自动返回 True
|
|
||||||
|
|
||||||
配置项(位于插件配置中)
|
- `ADMIN_QQ_GROUP`: `list[int]`
|
||||||
ADMIN_QQ_GROUP: list[int]
|
- 允许的管理员 QQ 群 ID 列表
|
||||||
允许的管理员 QQ 群 ID 列表
|
- `ADMIN_QQ_ACCOUNT`: `list[int]`
|
||||||
|
- 允许的管理员 QQ 账号 ID 列表
|
||||||
|
- `ADMIN_DISCORD_CHANNEL`: `list[int]`
|
||||||
|
- 允许的管理员 Discord 频道 ID 列表
|
||||||
|
- `ADMIN_DISCORD_ACCOUNT`: `list[int]`
|
||||||
|
- 允许的管理员 Discord 用户 ID 列表
|
||||||
|
|
||||||
ADMIN_QQ_ACCOUNT: list[int]
|
# 注意事项
|
||||||
允许的管理员 QQ 账号 ID 列表
|
|
||||||
|
|
||||||
ADMIN_DISCORD_CHANNEL: list[int]
|
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
||||||
允许的管理员 Discord 频道 ID 列表
|
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
||||||
|
|
||||||
ADMIN_DISCORD_ACCOUNT: list[int]
|
|
||||||
允许的管理员 Discord 用户 ID 列表
|
|
||||||
|
|
||||||
注意事项
|
|
||||||
- 若未在配置文件中设置任何管理员 ID,该函数对所有非控制台事件返回 False
|
|
||||||
- 控制台事件始终拥有管理员权限,便于本地调试与运维
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
konaph - KonaBot 的 PuzzleHunt 管理工具
|
|
||||||
|
`konaph` - KonaBot 的 PuzzleHunt 管理工具
|
||||||
|
|
||||||
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)
|
详细介绍请直接输入 konaph 获取使用指引(该指令权限仅对部分人开放。如果你有权限的话才有响应。建议在此方 BOT 私聊使用该指令。)
|
||||||
|
|||||||
@ -1,59 +1,83 @@
|
|||||||
指令介绍
|
# giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
||||||
giftool - 对 GIF 动图进行裁剪、抽帧等处理
|
|
||||||
|
|
||||||
格式
|
## 格式
|
||||||
giftool [图片] [选项]
|
|
||||||
|
|
||||||
示例
|
```bash
|
||||||
回复一张 GIF 并发送:
|
giftool [图片] [选项]
|
||||||
`giftool --ss 1.5 -t 2.0`
|
```
|
||||||
从 1.5 秒处开始,截取 2 秒长度的片段。
|
|
||||||
|
|
||||||
`giftool [图片] --ss 0:10 -to 0:15`
|
## 示例
|
||||||
截取从 10 秒到 15 秒之间的片段(支持 MM:SS 或 HH:MM:SS 格式)。
|
|
||||||
|
|
||||||
`giftool [图片] --frames:v 10`
|
- **回复一张 GIF 并发送:**
|
||||||
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
|
||||||
|
|
||||||
`giftool [图片] --ss 2 --frames:v 5`
|
```bash
|
||||||
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
giftool --ss 1.5 -t 2.0
|
||||||
|
```
|
||||||
|
|
||||||
参数说明
|
从 1.5 秒处开始,截取 2 秒长度的片段。
|
||||||
图片(必需)
|
|
||||||
- 必须是 GIF 动图。
|
|
||||||
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
|
||||||
|
|
||||||
--ss <时间戳>(可选)
|
- ```bash
|
||||||
- 指定开始时间(单位:秒),可使用以下格式:
|
giftool [图片] --ss 0:10 -to 0:15
|
||||||
• 纯数字(如 `1.5` 表示 1.5 秒)
|
```
|
||||||
• 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
|
||||||
• 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
|
||||||
- 默认从开头开始(0 秒)。
|
|
||||||
|
|
||||||
-t <持续时间>(可选)
|
截取从 10 秒到 15 秒之间的片段(支持 `MM:SS` 或 `HH:MM:SS` 格式)。
|
||||||
- 指定截取的持续时间(单位:秒),格式同 --ss。
|
|
||||||
- 与 --ss 配合使用:截取 [ss, ss + t] 区间。
|
|
||||||
- 不能与 --to 同时使用。
|
|
||||||
|
|
||||||
--to <时间戳>(可选)
|
- ```bash
|
||||||
- 指定结束时间(单位:秒),格式同 --ss。
|
giftool [图片] --frames:v 10
|
||||||
- 与 --ss 配合使用:截取 [ss, to] 区间。
|
```
|
||||||
- 不能与 -t 同时使用。
|
|
||||||
|
|
||||||
--frames:v <帧数>(可选)
|
将整张 GIF 均匀抽帧,最终保留 10 帧。
|
||||||
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
|
||||||
- 帧数必须为正整数(> 0)。
|
|
||||||
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
|
||||||
|
|
||||||
--speed <速度>(可选)
|
- ```bash
|
||||||
- 调整 gif 图的速度。若为负数,则代表倒放
|
giftool [图片] --ss 2 --frames:v 5
|
||||||
|
```
|
||||||
|
|
||||||
使用方式
|
从第 2 秒开始截取,并将结果抽帧为 5 帧。
|
||||||
1. 发送指令前,请确保:
|
|
||||||
- 消息中附带一张 GIF 动图,或
|
## 参数说明
|
||||||
- 回复一条包含 GIF 动图的消息后再发送指令。
|
|
||||||
2. 插件会自动:
|
### 图片(必需)
|
||||||
- 解析 GIF 的每一帧及其持续时间(duration)
|
|
||||||
- 根据时间参数转换为帧索引进行裁剪
|
- 必须是 GIF 动图。
|
||||||
- 如指定抽帧,则对裁剪后的片段均匀采样
|
- 支持直接附带图片,或回复一条含 GIF 的消息后使用指令。
|
||||||
- 生成新的 GIF 并保持原始循环设置(loop=0)
|
|
||||||
|
### `--ss <时间戳>`(可选)
|
||||||
|
|
||||||
|
- 指定开始时间(单位:秒),可使用以下格式:
|
||||||
|
- 纯数字(如 `1.5` 表示 1.5 秒)
|
||||||
|
- 分秒格式(如 `1:30` 表示 1 分 30 秒)
|
||||||
|
- 时分秒格式(如 `0:1:30` 表示 1 分 30 秒)
|
||||||
|
- 默认从开头开始(0 秒)。
|
||||||
|
|
||||||
|
### `-t <持续时间>`(可选)
|
||||||
|
|
||||||
|
- 指定截取的持续时间(单位:秒),格式同 `--ss`。
|
||||||
|
- 与 `--ss` 配合使用:截取 `[ss, ss + t]` 区间。
|
||||||
|
- **不能与 `--to` 同时使用。**
|
||||||
|
|
||||||
|
### `--to <时间戳>`(可选)
|
||||||
|
|
||||||
|
- 指定结束时间(单位:秒),格式同 `--ss`。
|
||||||
|
- 与 `--ss` 配合使用:截取 `[ss, to]` 区间。
|
||||||
|
- **不能与 `-t` 同时使用。**
|
||||||
|
|
||||||
|
### `--frames:v <帧数>`(可选)
|
||||||
|
|
||||||
|
- 对截取后的片段进行均匀抽帧,保留指定数量的帧。
|
||||||
|
- 帧数必须为正整数(> 0)。
|
||||||
|
- 若原始帧数 ≤ 指定帧数,则保留全部帧。
|
||||||
|
|
||||||
|
### `--speed <速度>`(可选)
|
||||||
|
|
||||||
|
- 调整 GIF 图的速度。若为负数,则代表倒放。
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
1. 发送指令前,请确保:
|
||||||
|
- 消息中附带一张 GIF 动图,**或**
|
||||||
|
- 回复一条包含 GIF 动图的消息后再发送指令。
|
||||||
|
2. 插件会自动:
|
||||||
|
- 解析 GIF 的每一帧及其持续时间(duration)
|
||||||
|
- 根据时间参数转换为帧索引进行裁剪
|
||||||
|
- 如指定抽帧,则对裁剪后的片段均匀采样
|
||||||
|
- 生成新的 GIF 并保持原始循环设置(`loop=0`)
|
||||||
|
|||||||
@ -1,20 +1,33 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
man - 用于展示此方 BOT 使用手册的指令
|
|
||||||
|
|
||||||
格式
|
`man` - 用于展示此方 BOT 使用手册的指令
|
||||||
man 文档类型
|
|
||||||
man [文档类型] <指令>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`man` 查看所有有文档的指令清单
|
|
||||||
`man 3` 列举所有可读文档的库函数清单
|
|
||||||
`man 喵` 查看指令「喵」的使用说明
|
|
||||||
`man 8 out` 查看管理员指令「out」的使用说明
|
|
||||||
|
|
||||||
文档类型
|
```
|
||||||
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为这些种类:
|
man 文档类型
|
||||||
|
man [文档类型] <指令>
|
||||||
|
```
|
||||||
|
|
||||||
- 1 用户态指令,用于日常使用的指令
|
## 示例
|
||||||
- 3 库函数指令,用于 Bot 开发用的函数查询
|
|
||||||
- 7 概念指令,用于概念解释
|
- ``man``
|
||||||
- 8 系统指令,仅管理员可用
|
查看所有有文档的指令清单
|
||||||
|
|
||||||
|
- ``man 3``
|
||||||
|
列举所有可读文档的库函数清单
|
||||||
|
|
||||||
|
- ``man 喵``
|
||||||
|
查看指令「喵」的使用说明
|
||||||
|
|
||||||
|
- ``man 8 out``
|
||||||
|
查看管理员指令「out」的使用说明
|
||||||
|
|
||||||
|
## 文档类型
|
||||||
|
|
||||||
|
文档类型用来区分同一指令在不同场景下的情景。你可以使用数字编号进行筛选。分为以下种类:
|
||||||
|
|
||||||
|
- **1** 用户态指令:用于日常使用的指令
|
||||||
|
- **3** 库函数指令:用于 Bot 开发用的函数查询
|
||||||
|
- **7** 概念指令:用于概念解释
|
||||||
|
- **8** 系统指令:仅管理员可用
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
ntfy - 配置使用 ntfy 来更好地为你通知此方 BOT 代办
|
**`ntfy`** - 配置使用 [ntfy](https://ntfy.sh/) 来更好地为你通知此方 BOT 的代办事项。
|
||||||
|
|
||||||
指令示例
|
## 指令示例
|
||||||
`ntfy 创建`
|
|
||||||
创建一个随机的 ntfy 订阅主题来提醒代办,此方 Bot 将会给你使用指引。你可以前往 https://ntfy.sh/ 官网下载 ntfy APP,或者使用网页版 ntfy。
|
|
||||||
|
|
||||||
`ntfy 创建 kagami-notice`
|
- **`ntfy 创建`**
|
||||||
创建一个名字含有 kagami-notice 的 ntfy 订阅主题
|
创建一个随机的 ntfy 订阅主题来提醒代办。此方 Bot 将会给你使用指引。你可以前往 [https://ntfy.sh/](https://ntfy.sh/) 官网下载 ntfy APP,或者使用网页版 ntfy。
|
||||||
|
|
||||||
`ntfy 删除`
|
- **`ntfy 创建 kagami-notice`**
|
||||||
清除并不再使用 ntfy 向你通知
|
创建一个名称包含 `kagami-notice` 的 ntfy 订阅主题。
|
||||||
|
|
||||||
另见
|
- **`ntfy 删除`**
|
||||||
提醒我(1) 查询提醒(1) 删除提醒(1)
|
清除配置,不再使用 ntfy 向你发送通知。
|
||||||
|
|
||||||
|
## 另见
|
||||||
|
[`提醒我(1)`](#) [`查询提醒(1)`](#) [`删除提醒(1)`](#)
|
||||||
|
|||||||
@ -1,21 +1,39 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
openssl - 用于生成指定长度的加密安全随机数据
|
|
||||||
|
|
||||||
格式
|
`openssl rand` — 用于生成指定长度的加密安全随机数据。
|
||||||
openssl rand <模式> <字节数>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`openssl rand -hex 16` 生成 16 字节的十六进制随机数
|
|
||||||
`openssl rand -base64 32` 生成 32 字节并以 Base64 编码输出的随机数据
|
|
||||||
|
|
||||||
说明
|
```bash
|
||||||
该指令使用 Python 的 secrets 模块生成加密安全的随机字节,并支持以十六进制(-hex)或 Base64(-base64)格式输出。
|
openssl rand <模式> <字节数>
|
||||||
|
```
|
||||||
|
|
||||||
参数说明
|
## 示例
|
||||||
模式(mode)
|
|
||||||
- -hex :以十六进制字符串形式输出随机数据
|
|
||||||
- -base64 :以 Base64 编码字符串形式输出随机数据
|
|
||||||
|
|
||||||
字节数(num)
|
- ```bash
|
||||||
- 必须为正整数
|
openssl rand -hex 16
|
||||||
- 最大支持 256 字节
|
```
|
||||||
|
生成 16 字节的十六进制随机数。
|
||||||
|
|
||||||
|
- ```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
生成 32 字节并以 Base64 编码输出的随机数据。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
该指令使用 Python 的 `secrets` 模块生成加密安全的随机字节,并支持以以下格式输出:
|
||||||
|
- 十六进制(`-hex`)
|
||||||
|
- Base64 编码(`-base64`)
|
||||||
|
|
||||||
|
## 参数说明
|
||||||
|
|
||||||
|
### 模式(mode)
|
||||||
|
|
||||||
|
- `-hex`:以十六进制字符串形式输出随机数据
|
||||||
|
- `-base64`:以 Base64 编码字符串形式输出随机数据
|
||||||
|
|
||||||
|
### 字节数(num)
|
||||||
|
|
||||||
|
- 必须为正整数
|
||||||
|
- 最大支持 256 字节
|
||||||
|
|||||||
@ -1,47 +1,55 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
shadertool - 使用 SkSL(Skia Shader Language)代码实时渲染并生成 GIF 动画
|
`shadertool` - 使用 SkSL(Skia Shader Language)代码实时渲染并生成 GIF 动画
|
||||||
|
|
||||||
格式
|
## 格式
|
||||||
shadertool [选项] <SkSL 代码>
|
```bash
|
||||||
|
shadertool [选项] <SkSL 代码>
|
||||||
|
```
|
||||||
|
|
||||||
示例
|
## 示例
|
||||||
shadertool """
|
```bash
|
||||||
uniform float u_time;
|
shadertool """
|
||||||
uniform float2 u_resolution;
|
uniform float u_time;
|
||||||
|
uniform float2 u_resolution;
|
||||||
|
|
||||||
half4 main(float2 coord) {
|
half4 main(float2 coord) {
|
||||||
return half4(
|
return half4(
|
||||||
1.0,
|
1.0,
|
||||||
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
sin((coord.y / u_resolution.y + u_time) * 3.1415926 * 2) * 0.5 + 0.5,
|
||||||
coord.x / u_resolution.x,
|
coord.x / u_resolution.x,
|
||||||
1.0
|
1.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
参数说明
|
## 参数说明
|
||||||
SkSL 代码(必填)
|
|
||||||
- 类型:字符串(建议用英文双引号包裹)
|
|
||||||
- 内容:符合 SkSL 语法的片段着色器代码,必须包含 `void main()` 函数,并为 `sk_FragColor` 赋值。
|
|
||||||
- 注意:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
|
||||||
|
|
||||||
--width <整数>(可选)
|
### SkSL 代码(必填)
|
||||||
- 默认值:320
|
- **类型**:字符串(建议用英文双引号包裹)
|
||||||
- 作用:输出 GIF 的宽度(像素),必须大于 0。
|
- **内容**:符合 SkSL 语法的片段着色器代码,必须包含 `main` 函数,并返回 `half4` 类型的颜色值。
|
||||||
|
- **注意**:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。
|
||||||
|
|
||||||
--height <整数>(可选)
|
### `--width <整数>`(可选)
|
||||||
- 默认值:180
|
- **默认值**:`320`
|
||||||
- 作用:输出 GIF 的高度(像素),必须大于 0。
|
- **作用**:输出 GIF 的宽度(像素),必须大于 0。
|
||||||
|
|
||||||
--duration <浮点数>(可选)
|
### `--height <整数>`(可选)
|
||||||
- 默认值:1.0
|
- **默认值**:`180`
|
||||||
- 作用:动画总时长(秒),必须大于 0。
|
- **作用**:输出 GIF 的高度(像素),必须大于 0。
|
||||||
- 限制:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
|
||||||
|
|
||||||
--fps <浮点数>(可选)
|
### `--duration <浮点数>`(可选)
|
||||||
- 默认值:15.0
|
- **默认值**:`1.0`
|
||||||
- 作用:每秒帧数,控制动画流畅度,必须大于 0。
|
- **作用**:动画总时长(秒),必须大于 0。
|
||||||
- 常见值:10(低配流畅)、15(默认)、24/30(电影/视频级)。
|
- **限制**:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。
|
||||||
|
|
||||||
使用方式
|
### `--fps <浮点数>`(可选)
|
||||||
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
- **默认值**:`15.0`
|
||||||
|
- **作用**:每秒帧数,控制动画流畅度,必须大于 0。
|
||||||
|
- **常见值**:
|
||||||
|
- `10`:低配流畅
|
||||||
|
- `15`:默认
|
||||||
|
- `24` / `30`:电影/视频级流畅度
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。
|
||||||
|
|||||||
@ -1,41 +1,72 @@
|
|||||||
指令介绍
|
# `ytpgif` 指令说明
|
||||||
ytpgif - 生成来回镜像翻转的仿 YTPMV 动图
|
|
||||||
|
|
||||||
格式
|
## 功能简介
|
||||||
ytpgif [倍速]
|
`ytpgif` 用于生成来回镜像翻转的仿 YTPMV(YouTube Poop Music Video)风格动图。
|
||||||
|
|
||||||
示例
|
---
|
||||||
`ytpgif`
|
|
||||||
使用默认倍速(1.0)处理你发送或回复的图片,生成镜像动图。
|
|
||||||
|
|
||||||
`ytpgif 2.5`
|
## 命令格式
|
||||||
以 2.5 倍速处理图片,生成更快节奏的镜像动图。
|
```bash
|
||||||
|
ytpgif [倍速]
|
||||||
|
```
|
||||||
|
|
||||||
回复一张图片并发送 `ytpgif 0.5`
|
---
|
||||||
以慢速(0.5 倍)生成镜像动图。
|
|
||||||
|
|
||||||
参数说明
|
## 使用示例
|
||||||
倍速(可选)
|
|
||||||
- 类型:浮点数
|
|
||||||
- 默认值:1.0
|
|
||||||
- 有效范围:0.1 ~ 20.0
|
|
||||||
- 作用:
|
|
||||||
• 对于静态图:控制镜像切换的快慢(值越大,切换越快)。
|
|
||||||
• 对于动图:控制截取原始动图正向和反向片段的时长(值越大,截取的片段越长)。
|
|
||||||
|
|
||||||
使用方式
|
- **默认倍速**
|
||||||
发送指令前,请确保:
|
```bash
|
||||||
- 直接在消息中附带一张图片,或
|
ytpgif
|
||||||
- 回复一条包含图片的消息后再发送指令。
|
```
|
||||||
|
使用默认倍速(1.0)处理你发送或回复的图片,生成镜像动图。
|
||||||
|
|
||||||
插件会自动:
|
- **指定倍速(较快)**
|
||||||
- 下载并识别图片(支持静态图和 GIF 动图)
|
```bash
|
||||||
- 自动缩放至最大边长不超过 256 像素(保持宽高比)
|
ytpgif 2.5
|
||||||
- 静态图 → 生成“原图↔镜像”循环动图
|
```
|
||||||
- 动图 → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图
|
以 2.5 倍速处理图片,生成节奏更快的镜像动图。
|
||||||
- 保留透明通道(如原图含透明),否则转为 RGB 避免颜色异常
|
|
||||||
|
|
||||||
注意事项
|
- **指定倍速(较慢)**
|
||||||
- 图片过大、格式损坏或网络问题可能导致处理失败。
|
回复一张图片并发送:
|
||||||
- 动图帧数过多或单帧过短可能无法生成有效输出。
|
```bash
|
||||||
- 输出 GIF 最大单段帧数限制为 500 帧,以防资源耗尽。
|
ytpgif 0.5
|
||||||
|
```
|
||||||
|
以 0.5 倍速生成慢节奏的镜像动图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参数说明
|
||||||
|
|
||||||
|
### `倍速`(可选)
|
||||||
|
- **类型**:浮点数
|
||||||
|
- **默认值**:`1.0`
|
||||||
|
- **有效范围**:`0.1 ~ 20.0`
|
||||||
|
|
||||||
|
#### 作用:
|
||||||
|
- **静态图**:控制“原图 ↔ 镜像”切换的速度(值越大,切换越快)。
|
||||||
|
- **GIF 动图**:控制截取原始动图正向与反向片段的时长(值越大,截取的片段越长)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
在发送指令前,请确保满足以下任一条件:
|
||||||
|
- 在消息中**直接附带一张图片**,或
|
||||||
|
- **回复一条包含图片的消息**后再发送指令。
|
||||||
|
|
||||||
|
插件将自动执行以下操作:
|
||||||
|
1. 下载并识别图片(支持静态图和 GIF 动图)。
|
||||||
|
2. 自动缩放图像,**最大边长不超过 256 像素**(保持宽高比)。
|
||||||
|
3. 根据图片类型处理:
|
||||||
|
- **静态图** → 生成“原图 ↔ 镜像”循环动图。
|
||||||
|
- **GIF 动图** → 截取开头一段正向播放 + 同一段镜像翻转播放,拼接成新动图。
|
||||||
|
4. **保留透明通道**(若原图含透明),否则转为 RGB 模式以避免颜色异常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
⚠️ 以下情况可能导致处理失败或效果不佳:
|
||||||
|
- 图片过大、格式损坏或网络问题;
|
||||||
|
- 动图帧数过多或单帧持续时间过短;
|
||||||
|
- 输出 GIF 单段帧数超过 **500 帧**(系统将自动限制以防资源耗尽)。
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
删除提醒 - 删除在`查询提醒(1)`中查到的提醒
|
**删除提醒** - 删除在 [`查询提醒(1)`](查询提醒(1)) 中查到的提醒
|
||||||
|
|
||||||
指令示例
|
## 指令示例
|
||||||
`删除提醒 1` 在查询提醒后,删除编号为 1 的提醒
|
`删除提醒 1`
|
||||||
|
在查询提醒后,删除编号为 1 的提醒
|
||||||
|
|
||||||
另见
|
## 另见
|
||||||
提醒我(1) 查询提醒(1) ntfy(1)
|
[`提醒我(1)`](提醒我(1)) [`查询提醒(1)`](查询提醒(1)) [`ntfy(1)`](ntfy(1))
|
||||||
|
|||||||
@ -1,20 +1,24 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
卵总展示 - 让卵总举起你的图片
|
|
||||||
|
|
||||||
格式
|
**卵总展示** - 让卵总举起你的图片
|
||||||
<引用图片> 卵总展示 [选项]
|
|
||||||
卵总展示 [选项] <图片>
|
|
||||||
|
|
||||||
选项
|
## 格式
|
||||||
`--whiteness <number>` 白度
|
|
||||||
将原图进行指数变换,以调整它的白的程度,默认为 0.0
|
|
||||||
|
|
||||||
`--black-level <number>` 黑色等级
|
```
|
||||||
将原图减淡,数值越大越淡,范围 0.0-1.0,默认 0.2
|
<引用图片> 卵总展示 [选项]
|
||||||
|
卵总展示 [选项] <图片>
|
||||||
|
```
|
||||||
|
|
||||||
`--opacity <number>` 不透明度
|
## 选项
|
||||||
将你的图片叠放在图片上的不透明度,默认为 0.8
|
|
||||||
|
|
||||||
`--saturation <number>` 饱和度
|
- `--whiteness <number>` **白度**
|
||||||
调整原图的饱和度,应该要大于 0.0,默认为 0.85
|
将原图进行指数变换,以调整它的白的程度,默认为 `0.0`。
|
||||||
|
|
||||||
|
- `--black-level <number>` **黑色等级**
|
||||||
|
将原图减淡,数值越大越淡,范围 `0.0–1.0`,默认为 `0.2`。
|
||||||
|
|
||||||
|
- `--opacity <number>` **不透明度**
|
||||||
|
将你的图片叠放在图片上的不透明度,默认为 `0.8`。
|
||||||
|
|
||||||
|
- `--saturation <number>` **饱和度**
|
||||||
|
调整原图的饱和度,应大于 `0.0`,默认为 `0.85`。
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
指令介绍
|
### 指令介绍
|
||||||
发起投票 - 发起一个投票
|
**发起投票** - 发起一个投票
|
||||||
|
|
||||||
格式
|
### 格式
|
||||||
发起投票 <投票标题> <选项1> <选项2> ...
|
```
|
||||||
|
发起投票 <投票标题> <选项1> <选项2> ...
|
||||||
|
```
|
||||||
|
|
||||||
示例
|
### 示例
|
||||||
`发起投票 这是一个投票 A B C` 发起标题为“这是一个投票”,选项为“A”、“B”、“C”的投票
|
`发起投票 这是一个投票 A B C`
|
||||||
|
发起标题为“这是一个投票”,选项为“A”、“B”、“C”的投票。
|
||||||
|
|
||||||
说明
|
### 说明
|
||||||
投票各个选项之间用空格分隔,选项数量为2-15项。投票的默认有效期为24小时。
|
- 投票的各个选项之间用空格分隔。
|
||||||
|
- 选项数量必须为 **2 到 15 项**。
|
||||||
|
- 投票的默认有效期为 **24 小时**。
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
喵 - 你发喵,此方就会回复喵
|
|
||||||
|
喵 - 你发喵,此方就会回复喵
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
投票 - 参与已发起的投票
|
**投票** - 参与已发起的投票
|
||||||
|
|
||||||
格式
|
## 格式
|
||||||
投票 <投票ID/标题> <选项文本>
|
```
|
||||||
|
投票 <投票ID/标题> <选项文本>
|
||||||
|
```
|
||||||
|
|
||||||
示例
|
## 示例
|
||||||
`投票 1 A` 在ID为1的投票中,投给“A”
|
- `投票 1 A`
|
||||||
`投票 这是一个投票 B` 在标题为“这是一个投票”的投票中,投给“B”
|
在 ID 为 1 的投票中,投给 “A”
|
||||||
|
- `投票 这是一个投票 B`
|
||||||
|
在标题为 “这是一个投票” 的投票中,投给 “B”
|
||||||
|
|
||||||
说明
|
## 说明
|
||||||
目前不支持单人多投,每个人只能投一项。
|
目前不支持单人多投,每个人只能投一项。
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
提醒我 - 在指定的时间提醒人事项的工具
|
|
||||||
|
|
||||||
使用示例
|
**提醒我** - 在指定的时间提醒人事项的工具
|
||||||
`下午五点提醒我吃饭`
|
|
||||||
创建一个下午五点的提醒,提醒你吃饭
|
|
||||||
|
|
||||||
`两分钟后提醒我睡觉`
|
## 使用示例
|
||||||
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
|
|
||||||
|
|
||||||
`2026年4月25日20点整提醒我生日快乐`
|
- `下午五点提醒我吃饭`
|
||||||
创建一个指定日期和时间的提醒
|
创建一个下午五点的提醒,提醒你吃饭
|
||||||
|
|
||||||
另见
|
- `两分钟后提醒我睡觉`
|
||||||
查询提醒(1) 删除提醒(1) ntfy(1)
|
创建一个相对于现在推迟 2 分钟的提醒,提醒你睡觉
|
||||||
|
|
||||||
|
- `2026年4月25日20点整提醒我生日快乐`
|
||||||
|
创建一个指定日期和时间的提醒
|
||||||
|
|
||||||
|
## 另见
|
||||||
|
|
||||||
|
[`查询提醒(1)`](查询提醒) [`删除提醒(1)`](删除提醒) [`ntfy(1)`](ntfy)
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
摇数字 - 生成一个随机数字并发送
|
|
||||||
|
|
||||||
示例
|
**摇数字** - 生成一个随机数字并发送
|
||||||
`摇数字` 随机生成一个 1-6 的数字
|
|
||||||
|
|
||||||
该指令不接受任何参数,直接调用即可。
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
摇数字
|
||||||
|
```
|
||||||
|
|
||||||
|
随机生成一个 1-6 的数字。
|
||||||
|
|
||||||
|
> 该指令不接受任何参数,直接调用即可。
|
||||||
|
|||||||
@ -1,22 +1,33 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
摇骰子 - 用于生成随机数并以骰子图像形式展示的指令
|
|
||||||
|
|
||||||
格式
|
**摇骰子** - 用于生成随机数并以骰子图像形式展示的指令
|
||||||
摇骰子 [最小值] [最大值]
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`摇骰子` 随机生成一个 1-6 的数字,并显示对应的骰子图像
|
|
||||||
`摇骰子 10` 生成 1 到 10 之间的随机整数
|
|
||||||
`摇骰子 0.5` 生成 0 到 0.5 之间的随机小数
|
|
||||||
`摇骰子 -5 5` 生成 -5 到 5 之间的随机数
|
|
||||||
|
|
||||||
说明
|
```
|
||||||
该指令支持以下几种调用方式:
|
摇骰子 [最小值] [最大值]
|
||||||
- 不带参数:使用默认范围生成随机数
|
```
|
||||||
- 仅指定一个参数 f1:
|
|
||||||
- 若 f1 > 1,则生成 [1, f1] 范围内的随机数
|
## 示例
|
||||||
- 若 0 < f1 ≤ 1,则生成 [0, f1] 范围内的随机数
|
|
||||||
- 若 f1 ≤ 0,则生成 [f1, 0] 范围内的随机数
|
- `摇骰子`
|
||||||
- 指定两个参数 f1 和 f2:生成 [f1, f2] 范围内的随机数(顺序无关,内部会自动处理大小)
|
随机生成一个 1–6 的数字,并显示对应的骰子图像
|
||||||
|
- `摇骰子 10`
|
||||||
|
生成 1 到 10 之间的随机整数
|
||||||
|
- `摇骰子 0.5`
|
||||||
|
生成 0 到 0.5 之间的随机小数
|
||||||
|
- `摇骰子 -5 5`
|
||||||
|
生成 -5 到 5 之间的随机数
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
该指令支持以下几种调用方式:
|
||||||
|
|
||||||
|
- **不带参数**:使用默认范围(1–6)生成随机数
|
||||||
|
- **仅指定一个参数 `f1`**:
|
||||||
|
- 若 `f1 > 1`,则生成 `[1, f1]` 范围内的随机数
|
||||||
|
- 若 `0 < f1 ≤ 1`,则生成 `[0, f1]` 范围内的随机数
|
||||||
|
- 若 `f1 ≤ 0`,则生成 `[f1, 0]` 范围内的随机数
|
||||||
|
- **指定两个参数 `f1` 和 `f2`**:生成 `[f1, f2]` 范围内的随机数(顺序无关,内部会自动处理大小)
|
||||||
|
|
||||||
返回结果将以骰子样式的图像形式展示生成的随机数值。
|
返回结果将以骰子样式的图像形式展示生成的随机数值。
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
查看投票 - 查看已发起的投票
|
|
||||||
|
|
||||||
格式
|
**查看投票** - 查看已发起的投票
|
||||||
查看投票 <投票ID或标题>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`查看投票 1` 查看ID为1的投票
|
|
||||||
`查看投票 这是一个投票` 查看标题为“这是一个投票”的投票
|
|
||||||
|
|
||||||
说明
|
```
|
||||||
投票在进行时,使用此命令可以看到投票的各个选项;投票结束后,则可以看到各项的票数。
|
查看投票 <投票ID或标题>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- `查看投票 1`
|
||||||
|
查看 ID 为 1 的投票
|
||||||
|
|
||||||
|
- `查看投票 这是一个投票`
|
||||||
|
查看标题为“这是一个投票”的投票
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 投票进行中时,使用此命令可查看投票的各个选项;
|
||||||
|
- 投票结束后,可查看各选项的最终票数。
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
查询提醒 - 查询已经创建的提醒
|
**查询提醒** - 查询已经创建的提醒
|
||||||
|
|
||||||
指令格式
|
## 指令格式
|
||||||
`查询提醒` 查询提醒
|
- `查询提醒`:查询提醒
|
||||||
`查询提醒 2` 查询第二页提醒
|
- `查询提醒 2`:查询第二页提醒
|
||||||
|
|
||||||
另见
|
## 另见
|
||||||
提醒我(1) 删除提醒(1) ntfy(1)
|
[提醒我(1)]() [删除提醒(1)]() [ntfy(1)]()
|
||||||
|
|||||||
@ -1,8 +1,17 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
生成二维码 - 将文本内容转换为二维码
|
|
||||||
|
|
||||||
格式
|
**生成二维码** - 将文本内容转换为二维码
|
||||||
生成二维码 <文本内容>
|
|
||||||
|
|
||||||
示例
|
### 格式
|
||||||
`生成二维码 嗨嗨嗨` 生成扫描结果为“嗨嗨嗨”的二维码图片
|
|
||||||
|
```
|
||||||
|
生成二维码 <文本内容>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
生成二维码 嗨嗨嗨
|
||||||
|
```
|
||||||
|
|
||||||
|
生成扫描结果为“嗨嗨嗨”的二维码图片
|
||||||
|
|||||||
30
konabot/docs/user/订阅.txt
Normal file
30
konabot/docs/user/订阅.txt
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# 指令介绍
|
||||||
|
|
||||||
|
**订阅** - 收听此方 BOT 的自动消息发送。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 格式
|
||||||
|
|
||||||
|
- `订阅 <频道名称>`
|
||||||
|
- `取消订阅 <频道名称>`
|
||||||
|
- `查询订阅 [页码]`
|
||||||
|
- `可用订阅 [页码]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- **`订阅 此方谜题`**
|
||||||
|
在当前的聊天上下文订阅「此方谜题」频道。此后会每天推送此方谜题(由 konaph(8) 管理的)。
|
||||||
|
- 如果你是私聊,则能够每天发送此方谜题到你的私聊;
|
||||||
|
- 如果在群聊中使用该指令,则会每天发送题目到这个群里面。
|
||||||
|
|
||||||
|
- **`取消订阅 此方谜题`**
|
||||||
|
取消订阅「此方谜题」频道。
|
||||||
|
|
||||||
|
- **`查询订阅`**
|
||||||
|
查询当前聊天上下文订阅的所有频道。
|
||||||
|
|
||||||
|
- **`可用订阅 2`**
|
||||||
|
查询所有可用的订阅的第二页。
|
||||||
@ -1,13 +1,20 @@
|
|||||||
指令介绍
|
# 指令介绍
|
||||||
雷达回波 - 用于获取指定地区的天气雷达回波图像
|
|
||||||
|
|
||||||
格式
|
**雷达回波** - 用于获取指定地区的天气雷达回波图像。
|
||||||
雷达回波 <地区>
|
|
||||||
|
|
||||||
示例
|
## 格式
|
||||||
`雷达回波 华南` 获取华南地区的天气雷达回波图
|
|
||||||
`雷达回波 全国` 获取全国的天气雷达回波图
|
|
||||||
|
|
||||||
说明
|
```
|
||||||
该指令通过查询中国气象局 https://www.nmc.cn/publish/radar/chinaall.html ,获取指定地区的实时天气雷达回波图像。
|
雷达回波 <地区>
|
||||||
支持的地区有:全国 华北 东北 华东 华中 华南 西南 西北。
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
- `雷达回波 华南`:获取华南地区的天气雷达回波图
|
||||||
|
- `雷达回波 全国`:获取全国的天气雷达回波图
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
该指令通过查询中国气象局 [https://www.nmc.cn/publish/radar/chinaall.html](https://www.nmc.cn/publish/radar/chinaall.html),获取指定地区的实时天气雷达回波图像。
|
||||||
|
|
||||||
|
支持的地区有:**全国**、**华北**、**东北**、**华东**、**华中**、**华南**、**西南**、**西北**。
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
指令介绍
|
## 指令介绍
|
||||||
黑白 - 将图片经过一个黑白滤镜的处理
|
|
||||||
|
|
||||||
示例
|
**黑白** - 将图片经过一个黑白滤镜的处理
|
||||||
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
引用一个带有图片的消息,或者消息本身携带图片,然后发送「黑白」即可
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ def get_puzzle_description(puzzle: Puzzle, with_answer: bool = False) -> UniMess
|
|||||||
if with_answer:
|
if with_answer:
|
||||||
result = result.text(f"\n\n题目答案:{puzzle.flag}")
|
result = result.text(f"\n\n题目答案:{puzzle.flag}")
|
||||||
else:
|
else:
|
||||||
result = result.text("\n\n输入「提交答案 答案」来提交你的解答")
|
result = result.text("\n\nAt 我或者私聊我「提交答案 答案」来提交你的解答")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import nonebot
|
import nonebot
|
||||||
import nonebot.adapters
|
import nonebot.adapters
|
||||||
import nonebot.adapters.discord
|
|
||||||
import nonebot.rule
|
import nonebot.rule
|
||||||
from nonebot import on_command
|
from nonebot import on_command
|
||||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||||
|
|
||||||
from konabot.common.nb.is_admin import is_admin
|
from konabot.common.nb.is_admin import is_admin
|
||||||
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
from konabot.common.path import DOCS_PATH_MAN1, DOCS_PATH_MAN3, DOCS_PATH_MAN7, DOCS_PATH_MAN8
|
||||||
|
from konabot.plugins.markdown.core import MarkDownCore
|
||||||
|
|
||||||
def search_man(section: int) -> dict[tuple[int, str], Path]:
|
def search_man(section: int) -> dict[tuple[int, str], Path]:
|
||||||
base_path = {
|
base_path = {
|
||||||
@ -64,7 +64,7 @@ async def _(
|
|||||||
for section in section_set:
|
for section in section_set:
|
||||||
mans += [f"{n}({s})" for s, n in search_man(section).keys()]
|
mans += [f"{n}({s})" for s, n in search_man(section).keys()]
|
||||||
mans.sort()
|
mans.sort()
|
||||||
|
|
||||||
await man.send(UniMessage().text(
|
await man.send(UniMessage().text(
|
||||||
(
|
(
|
||||||
"★此方 BOT 使用帮助★\n"
|
"★此方 BOT 使用帮助★\n"
|
||||||
@ -94,9 +94,9 @@ async def _(
|
|||||||
await man.send(UniMessage().text("你所检索的指令不存在"))
|
await man.send(UniMessage().text("你所检索的指令不存在"))
|
||||||
return
|
return
|
||||||
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
mans_msg = mans_fp.read_text('utf-8', 'replace')
|
||||||
if isinstance(event, nonebot.adapters.discord.event.MessageEvent):
|
# await man.send(UniMessage().text(mans_msg))
|
||||||
mans_msg = f'```\n{mans_msg}\n```'
|
img = await MarkDownCore.render_markdown(mans_msg)
|
||||||
await man.send(UniMessage().text(mans_msg))
|
await man.send(UniMessage.image(raw=img))
|
||||||
|
|
||||||
|
|
||||||
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
help_deprecated = on_command('help', rule=nonebot.rule.to_me())
|
||||||
|
|||||||
72
konabot/plugins/markdown/__init__.py
Normal file
72
konabot/plugins/markdown/__init__.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from loguru import logger
|
||||||
|
import nonebot
|
||||||
|
from nonebot.adapters import Event as BaseEvent
|
||||||
|
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||||
|
from nonebot_plugin_alconna import (
|
||||||
|
UniMessage,
|
||||||
|
UniMsg
|
||||||
|
)
|
||||||
|
|
||||||
|
from konabot.plugins.markdown.core import MarkDownCore
|
||||||
|
|
||||||
|
def is_markdown_mentioned(msg: UniMsg) -> bool:
|
||||||
|
txt = msg.extract_plain_text()
|
||||||
|
if "markdown" not in txt[:8] and "md" not in txt[:2]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
evt = nonebot.on_message(rule=is_markdown_mentioned)
|
||||||
|
|
||||||
|
@evt.handle()
|
||||||
|
async def _(msg: UniMsg, event: BaseEvent):
|
||||||
|
if isinstance(event, DiscordMessageEvent):
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
else:
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
|
||||||
|
logger.debug(f"Received markdown command with content: {content}")
|
||||||
|
if "md" in content[:2]:
|
||||||
|
message = content.replace("md", "", 1).strip()
|
||||||
|
else:
|
||||||
|
message = content.replace("markdown", "", 1).strip()
|
||||||
|
# 如果回复了消息,则转换回复的内容
|
||||||
|
if(len(message) == 0):
|
||||||
|
if event.reply:
|
||||||
|
message = event.reply.message.extract_plain_text()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
logger.debug(f"Markdown content to render: {message}")
|
||||||
|
|
||||||
|
out = await MarkDownCore.render_markdown(message, theme="dark")
|
||||||
|
|
||||||
|
await evt.send(await UniMessage().image(raw=out).export())
|
||||||
|
|
||||||
|
|
||||||
|
def is_latex_mentioned(evt: BaseEvent, msg: UniMsg) -> bool:
|
||||||
|
txt = msg.extract_plain_text()
|
||||||
|
if "latex" not in txt[:5]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
evt = nonebot.on_message(rule=is_latex_mentioned)
|
||||||
|
|
||||||
|
@evt.handle()
|
||||||
|
async def _(msg: UniMsg, event: BaseEvent):
|
||||||
|
if isinstance(event, DiscordMessageEvent):
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
else:
|
||||||
|
content = msg.extract_plain_text()
|
||||||
|
|
||||||
|
logger.debug(f"Received markdown command with content: {content}")
|
||||||
|
message = content.replace("latex", "", 1).strip()
|
||||||
|
# 如果回复了消息,则转换回复的内容
|
||||||
|
if(len(message) == 0):
|
||||||
|
if event.reply:
|
||||||
|
message = event.reply.message.extract_plain_text()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
logger.debug(f"Latex content to render: {message}")
|
||||||
|
|
||||||
|
out = await MarkDownCore.render_latex(message, theme="dark")
|
||||||
|
|
||||||
|
await evt.send(await UniMessage().image(raw=out).export())
|
||||||
57
konabot/plugins/markdown/core.py
Normal file
57
konabot/plugins/markdown/core.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from loguru import logger
|
||||||
|
from playwright.async_api import ConsoleMessage, Page
|
||||||
|
|
||||||
|
from konabot.common.web_render import konaweb
|
||||||
|
from konabot.common.web_render.core import WebRenderer
|
||||||
|
|
||||||
|
class MarkDownCore:
|
||||||
|
@staticmethod
|
||||||
|
async def render_markdown(markdown_text: str, theme: str = "dark", params: dict = {}) -> bytes:
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.emulate_media(color_scheme=theme)
|
||||||
|
|
||||||
|
await page.locator('textarea[name=content]').fill(markdown_text)
|
||||||
|
await page.locator('#button').click()
|
||||||
|
|
||||||
|
# 等待 checkState 函数加载完成
|
||||||
|
await page.wait_for_function("typeof checkState === 'function'", timeout=1000)
|
||||||
|
# 访问 checkState 函数,确保渲染完成
|
||||||
|
await page.wait_for_function("checkState() === true", timeout=1000)
|
||||||
|
|
||||||
|
out = await WebRenderer.render_with_persistent_page(
|
||||||
|
"markdown_renderer",
|
||||||
|
konaweb('markdown'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def render_latex(text: str, theme: str = "dark") -> bytes:
|
||||||
|
params = {
|
||||||
|
"size": "2.5em",
|
||||||
|
}
|
||||||
|
async def page_function(page: Page):
|
||||||
|
await page.emulate_media(color_scheme=theme)
|
||||||
|
|
||||||
|
page.wait_for_selector('textarea[name=content]')
|
||||||
|
await page.locator('textarea[name=content]').fill(f"$$ {text} $$")
|
||||||
|
page.wait_for_selector('#button')
|
||||||
|
await page.locator('#button').click()
|
||||||
|
|
||||||
|
# 等待 checkState 函数加载完成
|
||||||
|
await page.wait_for_function("typeof checkState === 'function'", timeout=2000)
|
||||||
|
# 访问 checkState 函数,确保渲染完成
|
||||||
|
await page.wait_for_function("checkState() === true", timeout=10000)
|
||||||
|
|
||||||
|
out = await WebRenderer.render_with_persistent_page(
|
||||||
|
"latex_renderer",
|
||||||
|
konaweb('latex'),
|
||||||
|
target='#main',
|
||||||
|
other_function=page_function,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
return out
|
||||||
@ -4,12 +4,17 @@ import PIL.Image
|
|||||||
from konabot.common.path import ASSETS_PATH
|
from konabot.common.path import ASSETS_PATH
|
||||||
from konabot.common.utils.to_async import make_async
|
from konabot.common.utils.to_async import make_async
|
||||||
|
|
||||||
doubao_watermark = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "doubao.png").convert("RGBA").resize((140, 40))
|
doubao_watermark = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "doubao.png").convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
@make_async
|
@make_async
|
||||||
def draw_doubao_watermark(base: PIL.Image.Image) -> PIL.Image.Image:
|
def draw_doubao_watermark(base: PIL.Image.Image) -> PIL.Image.Image:
|
||||||
base = base.copy().convert("RGBA")
|
base = base.copy().convert("RGBA")
|
||||||
base.alpha_composite(doubao_watermark, (base.size[0] - 160, base.size[1] - 60))
|
w = base.size[0] / 768 * 140
|
||||||
|
h = base.size[0] / 768 * 40
|
||||||
|
x = base.size[0] / 768 * 160
|
||||||
|
y = base.size[0] / 768 * 60
|
||||||
|
w, h, x, y = map(int, (w, h, x, y))
|
||||||
|
base.alpha_composite(doubao_watermark.resize((w, h)), (base.size[0] - x, base.size[1] - y))
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|||||||
85
konabot/plugins/poster/__init__.py
Normal file
85
konabot/plugins/poster/__init__.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import nonebot
|
||||||
|
from nonebot_plugin_alconna import Alconna, Args, on_alconna
|
||||||
|
|
||||||
|
from konabot.common.longtask import DepLongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery
|
||||||
|
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
||||||
|
from konabot.plugins.poster.service import dep_poster_service
|
||||||
|
|
||||||
|
|
||||||
|
cmd_subscribe = on_alconna(Alconna(
|
||||||
|
"订阅",
|
||||||
|
Args["channel", str],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_subscribe.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, channel: str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.subscribe(channel, target)
|
||||||
|
if result:
|
||||||
|
await target.send_message(f"已订阅「{channel}」")
|
||||||
|
else:
|
||||||
|
await target.send_message(f"已经订阅过「{channel}」了")
|
||||||
|
|
||||||
|
|
||||||
|
cmd_list = on_alconna(Alconna(
|
||||||
|
"re:(?:查询|我的|获取)订阅(列表)?",
|
||||||
|
Args["page?", int],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def better_channel_message(channel_id: str) -> str:
|
||||||
|
if channel_id not in POSTER_INFO_DATA:
|
||||||
|
return channel_id
|
||||||
|
data = POSTER_INFO_DATA[channel_id]
|
||||||
|
return f"{channel_id}:{data.description}"
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_list.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.get_channels(target, PagerQuery(
|
||||||
|
page_index=page,
|
||||||
|
page_size=10,
|
||||||
|
))
|
||||||
|
await target.send_message(result.to_unimessage(title="订阅列表", formatter=better_channel_message))
|
||||||
|
|
||||||
|
|
||||||
|
cmd_list_available = on_alconna(Alconna(
|
||||||
|
"re:(查询)?可用订阅(列表)?",
|
||||||
|
Args["page?", int],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_list_available.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||||
|
result = PagerQuery(
|
||||||
|
page_index=page,
|
||||||
|
page_size=10,
|
||||||
|
).apply(sorted(POSTER_INFO_DATA.keys()))
|
||||||
|
await target.send_message(result.to_unimessage(title="可用订阅列表", formatter=better_channel_message))
|
||||||
|
|
||||||
|
|
||||||
|
cmd_unsubscribe = on_alconna(Alconna(
|
||||||
|
"取消订阅",
|
||||||
|
Args["channel", str],
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@cmd_unsubscribe.handle()
|
||||||
|
async def _(target: DepLongTaskTarget, channel: str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
result = await service.subscribe(channel, target)
|
||||||
|
if result:
|
||||||
|
await target.send_message(f"已取消订阅「{channel}」")
|
||||||
|
else:
|
||||||
|
await target.send_message(f"这里没有订阅过「{channel}」")
|
||||||
|
|
||||||
|
|
||||||
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
@driver.on_startup
|
||||||
|
async def _():
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
await service.fix_data()
|
||||||
15
konabot/plugins/poster/poster_info.py
Normal file
15
konabot/plugins/poster/poster_info.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PosterInfo:
|
||||||
|
aliases: set[str] = field(default_factory=set)
|
||||||
|
description: str = field(default='')
|
||||||
|
|
||||||
|
|
||||||
|
POSTER_INFO_DATA: dict[str, PosterInfo] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_poster_info(channel: str, info: PosterInfo):
|
||||||
|
POSTER_INFO_DATA[channel] = info
|
||||||
|
|
||||||
112
konabot/plugins/poster/repo_local_data.py
Normal file
112
konabot/plugins/poster/repo_local_data.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
from konabot.common.path import DATA_PATH
|
||||||
|
from konabot.plugins.poster.repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelData(BaseModel):
|
||||||
|
targets: list[LongTaskTarget] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PosterData(BaseModel):
|
||||||
|
channels: dict[str, ChannelData] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_the_same_target(target1: LongTaskTarget, target2: LongTaskTarget) -> bool:
|
||||||
|
if (target1.is_private_chat and not target2.is_private_chat):
|
||||||
|
return False
|
||||||
|
if (target2.is_private_chat and not target1.is_private_chat):
|
||||||
|
return False
|
||||||
|
if target1.platform != target2.platform:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 如果是群聊,则要求 channel_id 相同
|
||||||
|
if not target1.is_private_chat:
|
||||||
|
return target1.channel_id == target2.channel_id
|
||||||
|
return target1.target_id == target2.target_id
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPosterRepo(IPosterRepo):
|
||||||
|
def __init__(self, data: PosterData) -> None:
|
||||||
|
self.data = data
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
|
||||||
|
if channel not in self.data.channels:
|
||||||
|
self.data.channels[channel] = ChannelData()
|
||||||
|
return self.data.channels[channel].targets
|
||||||
|
|
||||||
|
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
targets = await self.get_channel_targets(channel)
|
||||||
|
for t in targets:
|
||||||
|
if is_the_same_target(t, target):
|
||||||
|
return False
|
||||||
|
targets.append(target)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
targets = await self.get_channel_targets(channel)
|
||||||
|
len0 = len(targets)
|
||||||
|
self.data.channels[channel].targets = [
|
||||||
|
t for t in targets if not is_the_same_target(t, target)
|
||||||
|
]
|
||||||
|
len1 = len(self.data.channels[channel].targets)
|
||||||
|
return len0 != len1
|
||||||
|
|
||||||
|
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
channels: list[str] = []
|
||||||
|
for channel_id, channel in self.data.channels.items():
|
||||||
|
for t in channel.targets:
|
||||||
|
if is_the_same_target(target, t):
|
||||||
|
channels.append(channel_id)
|
||||||
|
break
|
||||||
|
channels = sorted(channels)
|
||||||
|
return pager.apply(channels)
|
||||||
|
|
||||||
|
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
|
||||||
|
channel_from = await self.get_channel_targets(from_channel)
|
||||||
|
channel_to = await self.get_channel_targets(to_channel)
|
||||||
|
|
||||||
|
for t1 in channel_from:
|
||||||
|
flag = True
|
||||||
|
for t2 in channel_to:
|
||||||
|
if is_the_same_target(t1, t2):
|
||||||
|
flag = False
|
||||||
|
break
|
||||||
|
if flag:
|
||||||
|
channel_to.append(t1)
|
||||||
|
|
||||||
|
del self.data.channels[from_channel]
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_POSTER_DATA_LOCK = asyncio.Lock()
|
||||||
|
LOCAL_POSTER_DATA_PATH = DATA_PATH / "module_poster_data.json"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def local_poster_data():
|
||||||
|
async with LOCAL_POSTER_DATA_LOCK:
|
||||||
|
if not LOCAL_POSTER_DATA_PATH.exists():
|
||||||
|
data = PosterData()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = PosterData.model_validate_json(LOCAL_POSTER_DATA_PATH.read_text())
|
||||||
|
except ValidationError:
|
||||||
|
data = PosterData()
|
||||||
|
yield data
|
||||||
|
LOCAL_POSTER_DATA_PATH.write_text(data.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def local_poster():
|
||||||
|
async with local_poster_data() as data:
|
||||||
|
yield LocalPosterRepo(data)
|
||||||
|
|
||||||
|
|
||||||
|
DepLocalPosterRepo = Annotated[LocalPosterRepo, Depends(local_poster)]
|
||||||
|
|
||||||
37
konabot/plugins/poster/repository.py
Normal file
37
konabot/plugins/poster/repository.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
|
||||||
|
|
||||||
|
class IPosterRepo(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_channel_targets(self, channel: str) -> list[LongTaskTarget]:
|
||||||
|
"""
|
||||||
|
获取广播通道的所有广播对象
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
"""
|
||||||
|
向广播通道添加一个广播目标。若目标已存在,则返回 False
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_channel_target(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
"""
|
||||||
|
移除一个广播通道的目标。若目标不存在,则返回 False
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_subscribed_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
"""
|
||||||
|
获得一个目标已经订阅了的广播通道
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def merge_channel(self, from_channel: str, to_channel: str) -> None:
|
||||||
|
"""
|
||||||
|
合并两个 Channel 为一个,并移除另一个
|
||||||
|
"""
|
||||||
|
|
||||||
59
konabot/plugins/poster/service.py
Normal file
59
konabot/plugins/poster/service.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from nonebot.params import Depends
|
||||||
|
from nonebot_plugin_alconna import UniMessage
|
||||||
|
from konabot.common.longtask import LongTaskTarget
|
||||||
|
from konabot.common.pager import PagerQuery, PagerResult
|
||||||
|
from konabot.plugins.poster.poster_info import POSTER_INFO_DATA
|
||||||
|
from konabot.plugins.poster.repo_local_data import local_poster
|
||||||
|
from konabot.plugins.poster.repository import IPosterRepo
|
||||||
|
|
||||||
|
|
||||||
|
class PosterService:
|
||||||
|
def __init__(self, repo: IPosterRepo) -> None:
|
||||||
|
self.repo = repo
|
||||||
|
|
||||||
|
def parse_channel_id(self, channel: str):
|
||||||
|
for cid, cinfo in POSTER_INFO_DATA.items():
|
||||||
|
if channel in cinfo.aliases:
|
||||||
|
return cid
|
||||||
|
return channel
|
||||||
|
|
||||||
|
async def subscribe(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
return await self.repo.add_channel_target(channel, target)
|
||||||
|
|
||||||
|
async def unsubscribe(self, channel: str, target: LongTaskTarget) -> bool:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
return await self.repo.remove_channel_target(channel, target)
|
||||||
|
|
||||||
|
async def broadcast(self, channel: str, message: UniMessage[Any] | str) -> list[LongTaskTarget]:
|
||||||
|
channel = self.parse_channel_id(channel)
|
||||||
|
targets = await self.repo.get_channel_targets(channel)
|
||||||
|
for target in targets:
|
||||||
|
# 因为是订阅消息,就不要 At 对方了
|
||||||
|
await target.send_message(message, at=False)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
async def get_channels(self, target: LongTaskTarget, pager: PagerQuery) -> PagerResult[str]:
|
||||||
|
return await self.repo.get_subscribed_channels(target, pager)
|
||||||
|
|
||||||
|
async def fix_data(self):
|
||||||
|
for cid, cinfo in POSTER_INFO_DATA.items():
|
||||||
|
for alias in cinfo.aliases:
|
||||||
|
await self.repo.merge_channel(alias, cid)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def dep_poster_service():
|
||||||
|
async with local_poster() as repo:
|
||||||
|
yield PosterService(repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(channel: str, message: UniMessage[Any] | str):
|
||||||
|
async with dep_poster_service() as service:
|
||||||
|
return await service.broadcast(channel, message)
|
||||||
|
|
||||||
|
|
||||||
|
DepPosterService = Annotated[PosterService, Depends(dep_poster_service)]
|
||||||
|
|
||||||
@ -6,7 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
import nanoid
|
import nanoid
|
||||||
import nonebot
|
import nonebot
|
||||||
import ptimeparse
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nonebot import get_plugin_config, on_message
|
from nonebot import get_plugin_config, on_message
|
||||||
from nonebot.adapters import Event
|
from nonebot.adapters import Event
|
||||||
@ -14,6 +13,7 @@ from nonebot_plugin_alconna import Alconna, Args, Subcommand, UniMessage, UniMsg
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
|
from konabot.common.longtask import DepLongTaskTarget, LongTask, create_longtask, handle_long_task, longtask_data
|
||||||
|
from konabot.common.ptimeparse import Parser
|
||||||
|
|
||||||
evt = on_message()
|
evt = on_message()
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ async def _(msg: UniMsg, mEvt: Event, target: DepLongTaskTarget):
|
|||||||
|
|
||||||
notify_time, notify_text = segments
|
notify_time, notify_text = segments
|
||||||
try:
|
try:
|
||||||
target_time = ptimeparse.Parser().parse(notify_time)
|
target_time = Parser().parse(notify_time)
|
||||||
logger.info(f"从 {notify_time} 解析出了时间:{target_time}")
|
logger.info(f"从 {notify_time} 解析出了时间:{target_time}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.info(f"无法从 {notify_time} 中解析出时间")
|
logger.info(f"无法从 {notify_time} 中解析出时间")
|
||||||
|
|||||||
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