Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37179fc4d7 | |||
| 56e0aabbf3 | |||
| ce2b7fd6f6 | |||
| b28f8f85a2 | |||
| 0acffea86d | |||
| 3e395f8a35 | |||
| 312e203bbe | |||
| f9deabfce0 | |||
| 7a20c3fe2f |
BIN
assets/webpage/ac/assets/background.png
Normal file
BIN
assets/webpage/ac/assets/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
76
assets/webpage/ac/index.html
Normal file
76
assets/webpage/ac/index.html
Normal file
@ -0,0 +1,76 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>空调炸炸排行榜</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="text">位居全球第 <span id="ranking" class="ranking">200</span>!</div>
|
||||
<div class="text-2">您的群总共坏了 <span id="number" class="number">200</span> 台空调</div>
|
||||
<img class="background" src="./assets/background.png" alt="空调炸炸排行榜">
|
||||
</div>
|
||||
</body>
|
||||
<style>
|
||||
.box {
|
||||
position: relative;
|
||||
width: 1024px;
|
||||
}
|
||||
.number {
|
||||
font-size: 2em;
|
||||
color: #ffdd00;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bold;
|
||||
font-stretch: 50%;
|
||||
max-width: 520px;
|
||||
word-wrap: break-word;
|
||||
line-height: 0.8em;
|
||||
}
|
||||
.background {
|
||||
width: 1024px;
|
||||
}
|
||||
.text {
|
||||
position: absolute;
|
||||
top: 125px;
|
||||
width: 100%;
|
||||
font-size: 72px;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bolder;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
.text-2 {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
width: 100%;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bolder;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
.ranking {
|
||||
font-size: 2em;
|
||||
color: #ff0000;
|
||||
-webkit-text-stroke: #ffffff 2px;
|
||||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.7);
|
||||
font-weight: bold;
|
||||
font-stretch: 50%;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// 从 URL 参数中获取 number 的值
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const number = urlParams.get('number');
|
||||
// 将 number 显示在页面上
|
||||
document.getElementById('number').textContent = number;
|
||||
// 从 URL 参数中获取 ranking 的值
|
||||
const ranking = urlParams.get('ranking');
|
||||
// 将 ranking 显示在页面上
|
||||
document.getElementById('ranking').textContent = ranking;
|
||||
</script>
|
||||
</html>
|
||||
@ -51,6 +51,10 @@ class LongTaskTarget(BaseModel):
|
||||
target_id: str
|
||||
"沟通对象的 ID"
|
||||
|
||||
@property
|
||||
def is_private_chat(self):
|
||||
return self.channel_id.startswith(QQ_PRIVATE_CHAT_CHANNEL_PREFIX)
|
||||
|
||||
async def send_message(self, msg: UniMessage | str, at: bool = True) -> bool:
|
||||
try:
|
||||
bot = nonebot.get_bot(self.self_id)
|
||||
|
||||
54
konabot/common/username.py
Normal file
54
konabot/common/username.py
Normal file
@ -0,0 +1,54 @@
|
||||
import re
|
||||
import nonebot
|
||||
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBBot
|
||||
|
||||
|
||||
class UsernameManager:
|
||||
grouped_data: dict[int, dict[int, str]]
|
||||
individual_data: dict[int, str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.grouped_data = {}
|
||||
self.individual_data = {}
|
||||
|
||||
async def update(self):
|
||||
for bot in nonebot.get_bots().values():
|
||||
if isinstance(bot, OBBot):
|
||||
for user in await bot.get_friend_list():
|
||||
uid = user["user_id"]
|
||||
nickname = user["nickname"]
|
||||
self.individual_data[uid] = nickname
|
||||
for group in await bot.get_group_list():
|
||||
gid = group["group_id"]
|
||||
for member in await bot.get_group_member_list(group_id=gid):
|
||||
uid = member["user_id"]
|
||||
card = member.get("card", "")
|
||||
nickname = member.get("nickname", "")
|
||||
if card:
|
||||
self.grouped_data.setdefault(gid, {})[uid] = card
|
||||
if nickname:
|
||||
self.individual_data[uid] = nickname
|
||||
|
||||
def get(self, qqid: int, groupid: int | None = None) -> str:
|
||||
if groupid is not None and groupid in self.grouped_data:
|
||||
n = self.grouped_data[groupid].get(qqid)
|
||||
if n is not None:
|
||||
return n
|
||||
if qqid in self.individual_data:
|
||||
return self.individual_data[qqid]
|
||||
return str(qqid)
|
||||
|
||||
|
||||
manager = UsernameManager()
|
||||
|
||||
def get_username(qqid: int | str, group: int | str | None = None):
|
||||
if isinstance(group, str):
|
||||
group = None if not re.match(r"^\d+$", group) else int(group)
|
||||
if isinstance(qqid, str):
|
||||
if re.match(r"^\d+$", qqid):
|
||||
qqid = int(qqid)
|
||||
else:
|
||||
return qqid
|
||||
return manager.get(qqid, group)
|
||||
|
||||
@ -80,6 +80,30 @@ class WebRenderer:
|
||||
page = cls.page_pool[page_id]
|
||||
return await instance.render_with_page(page, url, target, params=params, other_function=other_function, timeout=timeout)
|
||||
|
||||
@classmethod
|
||||
async def render_file(
|
||||
cls,
|
||||
file_path: str,
|
||||
target: str,
|
||||
params: dict = {},
|
||||
other_function: PageFunction | None = None,
|
||||
timeout: int = 30,
|
||||
) -> bytes:
|
||||
'''
|
||||
访问指定本地文件URL并返回截图
|
||||
|
||||
:param file_path: 目标文件路径
|
||||
:param target: 渲染目标,如 ".box"、"#main" 等CSS选择器
|
||||
:param timeout: 页面加载超时时间,单位秒
|
||||
:param params: URL键值对参数
|
||||
:param other_function: 其他自定义操作函数,接受page参数
|
||||
:return: 截图的字节数据
|
||||
|
||||
'''
|
||||
instance = await cls.get_browser_instance()
|
||||
logger.debug(f"Using WebRendererInstance {id(instance)} to render file {file_path} targeting {target}")
|
||||
return await instance.render_file(file_path, target, params=params, other_function=other_function, timeout=timeout)
|
||||
|
||||
@classmethod
|
||||
async def close_persistent_page(cls, page_id: str) -> None:
|
||||
'''
|
||||
@ -154,6 +178,10 @@ class WebRendererInstance:
|
||||
async with self.lock:
|
||||
screenshot = await self.inner_render(page, url, target, index, params, other_function, timeout)
|
||||
return screenshot
|
||||
|
||||
async def render_file(self, file_path: str, target: str, index: int = 0, params: dict = {}, other_function: callable = None, timeout: int = 30) -> bytes:
|
||||
file_path = "file:///" + str(file_path).replace("\\", "/")
|
||||
return await self.render(file_path, target, index, params, other_function, timeout)
|
||||
|
||||
async def inner_render(self, page: Page, url: str, target: str, index: int = 0, params: dict = {}, other_function: callable = None, timeout: int = 30) -> bytes:
|
||||
logger.debug(f"Navigating to {url} with timeout {timeout}")
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
from io import BytesIO
|
||||
from typing import Optional, Union
|
||||
import cv2
|
||||
from nonebot.adapters import Event as BaseEvent
|
||||
from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent
|
||||
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
|
||||
from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.path import ASSETS_PATH
|
||||
from konabot.plugins.air_conditioner.ac import AirConditioner, generate_ac_image
|
||||
from konabot.common.web_render import WebRenderer
|
||||
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
|
||||
|
||||
import random
|
||||
import math
|
||||
|
||||
def get_ac(id: str) -> AirConditioner:
|
||||
ac = AirConditioner.air_conditioners.get(id)
|
||||
@ -70,42 +76,58 @@ async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调升温"
|
||||
"空调升温",
|
||||
Args["temp?", Optional[Union[int, float]]] # 可选参数,升温的度数,默认为1
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||
if temp <= 0:
|
||||
return
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||
await send_ac_image(evt, ac)
|
||||
return
|
||||
ac.temperature += 1
|
||||
ac.temperature += temp
|
||||
if ac.temperature > 40:
|
||||
# 打开爆炸图片
|
||||
with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f:
|
||||
output = BytesIO()
|
||||
Image.open(f).save(output, format="GIF")
|
||||
await evt.send(await UniMessage().image(raw=output).export())
|
||||
ac.burnt = True
|
||||
await evt.send("太热啦,空调炸了!")
|
||||
return
|
||||
# 根据温度随机出是否爆炸,40度开始,呈指数增长
|
||||
possibility = -math.e ** ((40-ac.temperature) / 50) + 1
|
||||
if random.random() < possibility:
|
||||
# 打开爆炸图片
|
||||
with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f:
|
||||
output = BytesIO()
|
||||
# 爆炸抖动
|
||||
frames = wiggle_transform(np.array(Image.open(f)), intensity=5)
|
||||
pil_frames = [Image.fromarray(frame) for frame in frames]
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=35, disposal=2)
|
||||
output.seek(0)
|
||||
await evt.send(await UniMessage().image(raw=output).export())
|
||||
ac.broke_ac(CrashType.BURNT)
|
||||
await evt.send("太热啦,空调炸了!")
|
||||
return
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调降温"
|
||||
"空调降温",
|
||||
Args["temp?", Optional[Union[int, float]]] # 可选参数,降温的度数,默认为1
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
|
||||
if temp <= 0:
|
||||
return
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
if not ac.on or ac.burnt == True or ac.frozen == True:
|
||||
await send_ac_image(evt, ac)
|
||||
return
|
||||
ac.temperature -= 1
|
||||
ac.temperature -= temp
|
||||
if ac.temperature < 0:
|
||||
ac.frozen = True
|
||||
# 根据温度随机出是否冻结,0度开始,呈指数增长
|
||||
possibility = -math.e ** (ac.temperature / 50) + 1
|
||||
if random.random() < possibility:
|
||||
ac.broke_ac(CrashType.FROZEN)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
@ -117,4 +139,24 @@ async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
ac.change_ac()
|
||||
await send_ac_image(evt, ac)
|
||||
await send_ac_image(evt, ac)
|
||||
|
||||
evt = on_alconna(Alconna(
|
||||
"空调炸炸排行榜",
|
||||
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
|
||||
|
||||
@evt.handle()
|
||||
async def _(event: BaseEvent, target: DepLongTaskTarget):
|
||||
id = target.channel_id
|
||||
ac = get_ac(id)
|
||||
number, ranking = ac.get_crashes_and_ranking()
|
||||
params = {
|
||||
"number": number,
|
||||
"ranking": ranking
|
||||
}
|
||||
image = await WebRenderer.render_file(
|
||||
file_path=ASSETS_PATH / "webpage" / "ac" / "index.html",
|
||||
target=".box",
|
||||
params=params
|
||||
)
|
||||
await evt.send(await UniMessage().image(raw=image).export())
|
||||
@ -1,3 +1,4 @@
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
|
||||
import cv2
|
||||
@ -5,6 +6,12 @@ import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from konabot.common.path import ASSETS_PATH, FONTS_PATH
|
||||
from konabot.common.path import DATA_PATH
|
||||
import json
|
||||
|
||||
class CrashType(Enum):
|
||||
BURNT = 0
|
||||
FROZEN = 1
|
||||
|
||||
class AirConditioner:
|
||||
air_conditioners: dict[str, "AirConditioner"] = {}
|
||||
@ -23,6 +30,60 @@ class AirConditioner:
|
||||
self.on = False
|
||||
self.temperature = 24 # 重置为默认温度
|
||||
|
||||
def broke_ac(self, crash_type: CrashType):
|
||||
'''
|
||||
让空调坏掉,并保存数据
|
||||
|
||||
:param crash_type: CrashType 枚举,表示空调坏掉的类型
|
||||
'''
|
||||
match crash_type:
|
||||
case CrashType.BURNT:
|
||||
self.burnt = True
|
||||
case CrashType.FROZEN:
|
||||
self.frozen = True
|
||||
self.save_crash_data(crash_type)
|
||||
|
||||
def save_crash_data(self, crash_type: CrashType):
|
||||
'''
|
||||
如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
|
||||
'''
|
||||
data_file = DATA_PATH / "ac_crash_data.json"
|
||||
crash_data = {}
|
||||
if data_file.exists():
|
||||
with open(data_file, "r", encoding="utf-8") as f:
|
||||
crash_data = json.load(f)
|
||||
if self.id not in crash_data:
|
||||
crash_data[self.id] = {"burnt": 0, "frozen": 0}
|
||||
match crash_type:
|
||||
case CrashType.BURNT:
|
||||
crash_data[self.id]["burnt"] += 1
|
||||
case CrashType.FROZEN:
|
||||
crash_data[self.id]["frozen"] += 1
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(crash_data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
def get_crashes_and_ranking(self) -> tuple[int, int]:
|
||||
'''
|
||||
获取该群在全国空调损坏的数量与排行榜的位置
|
||||
'''
|
||||
data_file = DATA_PATH / "ac_crash_data.json"
|
||||
if not data_file.exists():
|
||||
return 0, 1
|
||||
with open(data_file, "r", encoding="utf-8") as f:
|
||||
crash_data = json.load(f)
|
||||
ranking_list = []
|
||||
for gid, record in crash_data.items():
|
||||
total = record.get("burnt", 0) + record.get("frozen", 0)
|
||||
ranking_list.append((gid, total))
|
||||
ranking_list.sort(key=lambda x: x[1], reverse=True)
|
||||
total_crashes = crash_data.get(self.id, {}).get("burnt", 0) + crash_data.get(self.id, {}).get("frozen", 0)
|
||||
rank = 1
|
||||
for gid, total in ranking_list:
|
||||
if gid == self.id:
|
||||
break
|
||||
rank += 1
|
||||
return total_crashes, rank
|
||||
|
||||
def text_to_transparent_image(text, font_size=40, padding=0, text_color=(0, 0, 0)):
|
||||
"""
|
||||
将文本转换为带透明背景的图像,图像大小刚好包含文本
|
||||
@ -168,13 +229,13 @@ def precise_blend_with_perspective(background, foreground, corners):
|
||||
|
||||
return result
|
||||
|
||||
def wiggle_transform(image) -> list[np.ndarray]:
|
||||
def wiggle_transform(image, intensity=2) -> list[np.ndarray]:
|
||||
'''
|
||||
返回一组图像振动的帧组,模拟空调运作时的抖动效果
|
||||
'''
|
||||
frames = []
|
||||
height, width = image.shape[:2]
|
||||
shifts = [(-2, 0), (2, 0), (0, -2), (0, 2), (0, 0)]
|
||||
shifts = [(-intensity, 0), (intensity, 0), (0, -intensity), (0, intensity), (0, 0)]
|
||||
for dx, dy in shifts:
|
||||
M = np.float32([[1, 0, dx], [0, 1, dy]])
|
||||
shifted = cv2.warpAffine(image, M, (width, height))
|
||||
@ -193,7 +254,7 @@ async def generate_ac_image(ac: AirConditioner) -> BytesIO:
|
||||
return output
|
||||
|
||||
# 根据生成温度文本图像
|
||||
text = f"{ac.temperature}°C"
|
||||
text = f"{round(ac.temperature, 1)}°C"
|
||||
text_image = text_to_transparent_image(
|
||||
text,
|
||||
font_size=60,
|
||||
@ -218,8 +279,10 @@ async def generate_ac_image(ac: AirConditioner) -> BytesIO:
|
||||
|
||||
final_image_simple = blend_with_transparency(ac_image, transformed_text, (0, 0))
|
||||
|
||||
frames = wiggle_transform(final_image_simple)
|
||||
intensity = max(2, abs(int(ac.temperature) - 24) // 2)
|
||||
|
||||
frames = wiggle_transform(final_image_simple, intensity=intensity)
|
||||
pil_frames = [Image.fromarray(frame) for frame in frames]
|
||||
output = BytesIO()
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50)
|
||||
pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50, disposal=2)
|
||||
return output
|
||||
@ -1,7 +1,10 @@
|
||||
from functools import reduce
|
||||
from math import ceil
|
||||
import re
|
||||
from loguru import logger
|
||||
from nonebot_plugin_alconna import Alconna, Args, UniMessage, on_alconna
|
||||
from konabot.common.nb.qq_broadcast import qq_broadcast
|
||||
from konabot.common.username import get_username
|
||||
from konabot.plugins.kona_ph.core.storage import get_today_date
|
||||
from konabot.plugins.kona_ph.manager import PUZZLE_PAGE_SIZE, create_admin_commands, config, puzzle_manager
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
@ -13,9 +16,9 @@ create_admin_commands()
|
||||
|
||||
|
||||
async def is_play_group(target: DepLongTaskTarget):
|
||||
if target.channel_id in config.plugin_puzzle_playgroup:
|
||||
if target.is_private_chat:
|
||||
return True
|
||||
if target.target_id in target.channel_id:
|
||||
if target.channel_id in config.plugin_puzzle_playgroup:
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -33,7 +36,7 @@ async def _(flag: str, target: DepLongTaskTarget):
|
||||
|
||||
|
||||
cmd_query = on_alconna(Alconna(
|
||||
r"re:(?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?"
|
||||
r"re:(?:((?:(?:所以|话)说?)?今天的题目是什么[啊呀哇呢]?(?:\??)?)|今日谜?题目?)"
|
||||
), rule=is_play_group)
|
||||
|
||||
@cmd_query.handle()
|
||||
@ -45,6 +48,52 @@ async def _(target: DepLongTaskTarget):
|
||||
await target.send_message(p.get_unimessage())
|
||||
|
||||
|
||||
cmd_query_submission = on_alconna(Alconna(
|
||||
"今日答题情况"
|
||||
), rule=is_play_group)
|
||||
|
||||
@cmd_query_submission.handle()
|
||||
async def _(target: DepLongTaskTarget):
|
||||
async with puzzle_manager() as manager:
|
||||
p = manager.get_today_puzzle()
|
||||
if p is None:
|
||||
return await target.send_message("今天无题")
|
||||
msg = UniMessage.text("==== 今日答题情况 ====\n\n")
|
||||
|
||||
subcount = len(reduce(
|
||||
lambda x, y: x + y,
|
||||
manager.submissions.get(p.raw_id, {}).values(),
|
||||
[],
|
||||
))
|
||||
info = manager.daily_puzzle[p.index_id]
|
||||
|
||||
msg = msg.text(
|
||||
f"总体情况:答对 {len(info.success_users)} / "
|
||||
f"参与 {len(info.tried_users)} / "
|
||||
f"提交 {subcount}\n"
|
||||
)
|
||||
|
||||
success_users = sorted(list(info.success_users.items()), key=lambda v: v[1])
|
||||
gid = None
|
||||
if re.match(r"^\d+$", target.channel_id):
|
||||
gid = int(target.channel_id)
|
||||
for u, d in success_users:
|
||||
uname = u
|
||||
if re.match(r"^\d+$", u):
|
||||
uname = get_username(int(u), gid)
|
||||
t = d.strftime("%H:%M")
|
||||
tries = len(manager.submissions[p.raw_id][u])
|
||||
msg = msg.text(f"\n- {uname} [🎉 {t} 完成 | {tries} 提交]")
|
||||
for u in info.tried_users - set(info.success_users.keys()):
|
||||
uname = u
|
||||
if re.match(r"^\d+$", u):
|
||||
uname = get_username(int(u), gid)
|
||||
tries = len(manager.submissions[p.raw_id][u])
|
||||
msg = msg.text(f"\n- {uname} [💦 {tries} 提交]")
|
||||
|
||||
await target.send_message(msg)
|
||||
|
||||
|
||||
cmd_history = on_alconna(Alconna(
|
||||
"历史题目",
|
||||
Args["page?", int],
|
||||
@ -92,6 +141,10 @@ async def _(target: DepLongTaskTarget, index_id: str = "", page: int = 1):
|
||||
@scheduler.scheduled_job("cron", hour="8")
|
||||
async def _():
|
||||
async with puzzle_manager() as manager:
|
||||
msg2 = manager.get_report_yesterday()
|
||||
if msg2 is not None:
|
||||
await qq_broadcast(config.plugin_puzzle_playgroup, msg2)
|
||||
|
||||
puzzle = manager.get_today_puzzle()
|
||||
if puzzle is not None:
|
||||
logger.info(f"找到了题目 {puzzle.raw_id},发送")
|
||||
@ -99,3 +152,4 @@ async def _():
|
||||
else:
|
||||
logger.info("自动任务:没有找到题目,跳过")
|
||||
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from nonebot_plugin_alconna import UniMessage
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from konabot.common.path import DATA_PATH
|
||||
from konabot.common.username import get_username
|
||||
|
||||
|
||||
KONAPH_BASE = DATA_PATH / "KonaPH"
|
||||
@ -37,9 +38,6 @@ class Puzzle(BaseModel):
|
||||
flag: str
|
||||
|
||||
ready: bool = False
|
||||
published: bool = False
|
||||
pinned: bool = False
|
||||
|
||||
created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
|
||||
|
||||
def get_image_path(self) -> Path:
|
||||
@ -52,7 +50,7 @@ class Puzzle(BaseModel):
|
||||
if self.img_name:
|
||||
result = result.text("\n\n").image(raw=self.get_image_path().read_bytes())
|
||||
|
||||
result = result.text("\n\n出题者:").at(self.author_id)
|
||||
result = result.text(f"\n\n出题者:{get_username(self.author_id)}")
|
||||
result = result.text("\n\n输入「提交答案 答案」来提交你的解答")
|
||||
|
||||
return result
|
||||
@ -111,68 +109,81 @@ class PuzzleManager(BaseModel):
|
||||
daily_puzzle_of_date: dict[datetime.date, str] = {}
|
||||
|
||||
puzzle_pinned: str = ""
|
||||
unpublished_puzzles: set[str] = set()
|
||||
unready_puzzles: set[str] = set()
|
||||
published_puzzles: set[str] = set()
|
||||
|
||||
index_id_counter: int = 1
|
||||
submissions: dict[str, dict[str, list[PuzzleSubmission]]] = {}
|
||||
last_pubish_date: datetime.date = Field(
|
||||
default_factory=lambda: get_today_date() - datetime.timedelta(days=1)
|
||||
)
|
||||
last_checked_date: datetime.date = Field(
|
||||
default_factory=lambda: get_today_date() - datetime.timedelta(days=1)
|
||||
)
|
||||
|
||||
@property
|
||||
def last_publish_date(self):
|
||||
return max(self.daily_puzzle_of_date.keys())
|
||||
|
||||
@property
|
||||
def unpublished_puzzles(self):
|
||||
return set((
|
||||
p.raw_id for p in self.puzzle_data.values()
|
||||
if not self.is_puzzle_published(p.raw_id) and p.ready
|
||||
))
|
||||
|
||||
@property
|
||||
def unready_puzzles(self):
|
||||
return set((
|
||||
p.raw_id for p in self.puzzle_data.values()
|
||||
if not self.is_puzzle_published(p.raw_id) and not p.ready
|
||||
))
|
||||
|
||||
@property
|
||||
def published_puzzles(self):
|
||||
return set((
|
||||
p.raw_id for p in self.puzzle_data.values()
|
||||
if self.is_puzzle_published(p.raw_id)
|
||||
))
|
||||
|
||||
def is_puzzle_published(self, raw_id: str):
|
||||
return raw_id in [i.raw_id for i in self.daily_puzzle.values()]
|
||||
|
||||
def publish_puzzle(self, raw_id: str):
|
||||
assert raw_id in self.puzzle_data
|
||||
|
||||
self.unpublished_puzzles -= set(raw_id)
|
||||
self.unready_puzzles -= set(raw_id)
|
||||
today = get_today_date()
|
||||
|
||||
p = self.puzzle_data[raw_id]
|
||||
p.index_id = str(self.index_id_counter)
|
||||
p.ready = True
|
||||
p.published = True
|
||||
p.pinned = False
|
||||
self.puzzle_pinned = ""
|
||||
self.last_pubish_date = get_today_date()
|
||||
self.last_checked_date = self.last_pubish_date
|
||||
self.last_checked_date = today
|
||||
self.daily_puzzle[p.index_id] = DailyPuzzleInfo(
|
||||
raw_id=raw_id,
|
||||
time=self.last_pubish_date,
|
||||
time=today,
|
||||
)
|
||||
self.daily_puzzle_of_date[self.last_pubish_date] = p.index_id
|
||||
self.published_puzzles.add(raw_id)
|
||||
self.daily_puzzle_of_date[today] = p.index_id
|
||||
|
||||
self.index_id_counter += 1
|
||||
|
||||
def admin_mark_ready(self, raw_id: str, ready: bool = True):
|
||||
if raw_id not in self.puzzle_data:
|
||||
return
|
||||
if ready:
|
||||
self.unready_puzzles -= set(raw_id)
|
||||
if raw_id not in self.published_puzzles:
|
||||
self.unpublished_puzzles.add(raw_id)
|
||||
p = self.puzzle_data[raw_id]
|
||||
p.ready = True
|
||||
p.published = raw_id in self.published_puzzles
|
||||
else:
|
||||
self.unready_puzzles.add(raw_id)
|
||||
self.unpublished_puzzles -= set(raw_id)
|
||||
p = self.puzzle_data[raw_id]
|
||||
p.ready = False
|
||||
p.published = False
|
||||
# if p.raw_id == self.puzzle_pinned:
|
||||
# self.puzzle_pinned = ""
|
||||
def fix(self):
|
||||
# 尝试修复今日的数据
|
||||
for p in self.puzzle_data.values():
|
||||
if self.is_puzzle_published(p.raw_id):
|
||||
p.ready = True
|
||||
|
||||
if self.puzzle_pinned not in self.unpublished_puzzles:
|
||||
self.puzzle_pinned = ""
|
||||
|
||||
# 撤回重复发布的题
|
||||
already_published: set[str] = set()
|
||||
for date in list(self.daily_puzzle_of_date.keys()):
|
||||
index_id = self.daily_puzzle_of_date[date]
|
||||
info = self.daily_puzzle[index_id]
|
||||
if info.raw_id in already_published:
|
||||
del self.daily_puzzle[index_id]
|
||||
del self.daily_puzzle_of_date[date]
|
||||
else:
|
||||
already_published.add(info.raw_id)
|
||||
|
||||
def admin_pin_puzzle(self, raw_id: str):
|
||||
if self.puzzle_pinned:
|
||||
p = self.puzzle_data.get(self.puzzle_pinned)
|
||||
if p is not None:
|
||||
p.pinned = False
|
||||
if raw_id in self.puzzle_data:
|
||||
p = self.puzzle_data[raw_id]
|
||||
p.pinned = True
|
||||
self.puzzle_pinned = raw_id
|
||||
else:
|
||||
self.puzzle_pinned = ""
|
||||
@ -252,9 +263,7 @@ class PuzzleManager(BaseModel):
|
||||
author_id=user,
|
||||
flag="konaph{this_is_a_flag}",
|
||||
ready=False,
|
||||
published=False,
|
||||
)
|
||||
self.unready_puzzles.add(p.raw_id)
|
||||
self.puzzle_data[p.raw_id] = p
|
||||
return p
|
||||
|
||||
@ -286,9 +295,9 @@ class PuzzleManager(BaseModel):
|
||||
us = us[:5]
|
||||
for u, _ in us:
|
||||
m = self.submissions[puzzle.raw_id][u][-1]
|
||||
message = message.text("- ").at(u).text(f" 于 {m.time.strftime('%H:%M')}")
|
||||
message = message.text(f"- {get_username(u)} 于 {m.time.strftime('%H:%M')}")
|
||||
|
||||
message = message.text("\n\n出题者:").at(puzzle.author_id)
|
||||
message = message.text(f"\n\n出题者:{get_username(puzzle.author_id)}")
|
||||
return message
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,8 @@ from pydantic import BaseModel
|
||||
from konabot.common.longtask import DepLongTaskTarget
|
||||
from konabot.common.nb.extract_image import download_image_bytes
|
||||
from konabot.common.nb.qq_broadcast import qq_broadcast
|
||||
from konabot.plugins.kona_ph.core.storage import Puzzle, get_today_date, puzzle_manager
|
||||
from konabot.common.username import get_username
|
||||
from konabot.plugins.kona_ph.core.storage import Puzzle, PuzzleManager, get_today_date, puzzle_manager
|
||||
|
||||
PUZZLE_PAGE_SIZE = 10
|
||||
|
||||
@ -30,22 +31,22 @@ def is_puzzle_admin(target: DepLongTaskTarget):
|
||||
return target.target_id in config.plugin_puzzle_admin
|
||||
|
||||
|
||||
def get_puzzle_info_message(puzzle: Puzzle) -> UniMessage[Any]:
|
||||
status = "✅ 已准备,待发布" if puzzle.ready and not puzzle.published else \
|
||||
(f"🟢 已发布: #{puzzle.index_id}" if puzzle.published else "⚙️ 未准备")
|
||||
def get_puzzle_info_message(manager: PuzzleManager, puzzle: Puzzle) -> UniMessage[Any]:
|
||||
status = "✅ 已准备,待发布" if puzzle.ready and not manager.is_puzzle_published(puzzle.raw_id) else \
|
||||
(f"🟢 已发布: #{puzzle.index_id}" if manager.is_puzzle_published(puzzle.raw_id) else "⚙️ 未准备")
|
||||
|
||||
status_suffix = ""
|
||||
if puzzle.pinned:
|
||||
if puzzle.raw_id == manager.puzzle_pinned:
|
||||
status_suffix += " | 📌 已被管理员置顶"
|
||||
|
||||
msg = UniMessage.text(
|
||||
f"--- 谜题信息 ---\n"
|
||||
f"Raw ID: {puzzle.raw_id}\n"
|
||||
f"标题: {puzzle.title}\n"
|
||||
f"出题者 ID: {puzzle.author_id}\n"
|
||||
f"出题者: {get_username(puzzle.author_id)} | {puzzle.author_id}\n"
|
||||
f"创建时间: {puzzle.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"Flag: {puzzle.flag}\n"
|
||||
f"状态: {status}{status_suffix}\n\n"
|
||||
f"标题: {puzzle.title}\n"
|
||||
f"Flag: {puzzle.flag}\n\n"
|
||||
f"{puzzle.content}"
|
||||
)
|
||||
|
||||
@ -80,6 +81,8 @@ def create_admin_commands():
|
||||
dest="modify",
|
||||
),
|
||||
Subcommand("publish", Args["raw_id?", str], dest="publish"),
|
||||
Subcommand("preview", Args["raw_id", str], dest="preview"),
|
||||
Subcommand("get-submits", Args["raw_id", str], dest="get-submits")
|
||||
),
|
||||
rule=is_puzzle_manager,
|
||||
)
|
||||
@ -93,6 +96,8 @@ def create_admin_commands():
|
||||
msg = msg.text("konaph info <id> - 查看谜题\n")
|
||||
msg = msg.text("konaph my <page?> - 查看我的谜题列表\n")
|
||||
msg = msg.text("konaph modify - 查看如何修改谜题信息\n")
|
||||
msg = msg.text("konaph preview <id> - 预览一个题目的效果,不会展示答案\n")
|
||||
msg = msg.text("konaph get-submits <id> - 获得题目的提交记录\n")
|
||||
|
||||
if is_puzzle_admin(target):
|
||||
msg = msg.text("konaph all [--ready] <page?> - 查看所有谜题\n")
|
||||
@ -130,7 +135,7 @@ def create_admin_commands():
|
||||
return await target.send_message(UniMessage.text(
|
||||
"题目早就准备好啦!"
|
||||
))
|
||||
manager.admin_mark_ready(raw_id, True)
|
||||
p.ready = True
|
||||
await target.send_message(UniMessage.text(
|
||||
f"谜题「{p.title}」已经准备就绪!"
|
||||
))
|
||||
@ -151,12 +156,12 @@ def create_admin_commands():
|
||||
return await target.send_message(UniMessage.text(
|
||||
f"谜题「{p.title}」已经是未取消状态了!"
|
||||
))
|
||||
if p.published:
|
||||
if manager.is_puzzle_published(p.raw_id):
|
||||
return await target.send_message(UniMessage.text(
|
||||
"已发布的谜题不能取消准备状态!"
|
||||
))
|
||||
|
||||
manager.admin_mark_ready(raw_id, False)
|
||||
p.ready = False
|
||||
await target.send_message(UniMessage.text(
|
||||
f"谜题「{p.title}」已经取消准备!"
|
||||
))
|
||||
@ -174,7 +179,7 @@ def create_admin_commands():
|
||||
"这不是你的题,你没有权限查看详细信息!"
|
||||
))
|
||||
|
||||
await target.send_message(get_puzzle_info_message(p))
|
||||
await target.send_message(get_puzzle_info_message(manager, p))
|
||||
|
||||
@cmd_admin.assign("my")
|
||||
async def _(target: DepLongTaskTarget, page: int = 1):
|
||||
@ -193,10 +198,10 @@ def create_admin_commands():
|
||||
message = UniMessage.text("==== 我的谜题 ====\n\n")
|
||||
for p in puzzles:
|
||||
message = message.text("- ")
|
||||
if p.pinned:
|
||||
if manager.puzzle_pinned == p.raw_id:
|
||||
message = message.text("[📌]")
|
||||
if p.published:
|
||||
message = message.text(f"[#{p.index_id}] ")
|
||||
if manager.is_puzzle_published(p.raw_id):
|
||||
message = message.text(f"[✨][#{p.index_id}] ")
|
||||
elif p.ready:
|
||||
message = message.text("[✅] ")
|
||||
else:
|
||||
@ -207,7 +212,7 @@ def create_admin_commands():
|
||||
await target.send_message(message)
|
||||
|
||||
@cmd_admin.assign("all")
|
||||
async def _(target: DepLongTaskTarget, ready: Query[bool] = Query("ready"), page: int = 1):
|
||||
async def _(target: DepLongTaskTarget, ready: Query[bool] = Query("all.ready"), page: int = 1):
|
||||
if not is_puzzle_admin(target):
|
||||
return await target.send_message(UniMessage.text("你没有权限查看所有的哦"))
|
||||
async with puzzle_manager() as manager:
|
||||
@ -224,10 +229,10 @@ def create_admin_commands():
|
||||
message = UniMessage.text("==== 所有谜题 ====\n\n")
|
||||
for p in puzzles:
|
||||
message = message.text("- ")
|
||||
if p.pinned:
|
||||
if p.raw_id == manager.puzzle_pinned:
|
||||
message = message.text("[📌]")
|
||||
if p.published:
|
||||
message = message.text(f"[#{p.index_id}] ")
|
||||
if manager.is_puzzle_published(p.raw_id):
|
||||
message = message.text(f"[✨][#{p.index_id}] ")
|
||||
elif p.ready:
|
||||
message = message.text("[✅] ")
|
||||
else:
|
||||
@ -276,7 +281,7 @@ def create_admin_commands():
|
||||
description: str | None = None,
|
||||
flag: str | None = None,
|
||||
image: Image | None = None,
|
||||
remove_image: Query[bool] = Query("--remove-image"),
|
||||
remove_image: Query[bool] = Query("modify.remove-image"),
|
||||
):
|
||||
if raw_id == "":
|
||||
return await target.send_message(
|
||||
@ -284,7 +289,7 @@ def create_admin_commands():
|
||||
"支持的参数:\n"
|
||||
" --title <str> 标题\n"
|
||||
" --description <str> 题目详情描述(用直引号包裹以支持多行)\n"
|
||||
" --flag <str> flag\n"
|
||||
" --flag <str> flag,也就是题目的答案\n"
|
||||
" --image <图片> 图片\n"
|
||||
" --remove-image 删除图片"
|
||||
)
|
||||
@ -307,7 +312,7 @@ def create_admin_commands():
|
||||
elif remove_image.available:
|
||||
p.remove_image()
|
||||
|
||||
info2 = get_puzzle_info_message(p)
|
||||
info2 = get_puzzle_info_message(manager, p)
|
||||
|
||||
return await target.send_message("修改好啦!看看效果:\n\n" + info2)
|
||||
|
||||
@ -326,4 +331,31 @@ def create_admin_commands():
|
||||
await qq_broadcast(config.plugin_puzzle_playgroup, p.get_unimessage())
|
||||
return await target.send_message("Ok!")
|
||||
|
||||
@cmd_admin.assign("preview")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str):
|
||||
async with puzzle_manager() as manager:
|
||||
puzzle = manager.puzzle_data.get(raw_id)
|
||||
if puzzle is None:
|
||||
return await target.send_message("没有这个谜题")
|
||||
if not is_puzzle_admin(target) and target.target_id != puzzle.author_id:
|
||||
return await target.send_message("你没有权限预览这个谜题")
|
||||
return await target.send_message(puzzle.get_unimessage())
|
||||
|
||||
@cmd_admin.assign("get-submits")
|
||||
async def _(target: DepLongTaskTarget, raw_id: str):
|
||||
async with puzzle_manager() as manager:
|
||||
puzzle = manager.puzzle_data.get(raw_id)
|
||||
if puzzle is None:
|
||||
return await target.send_message("没有这个谜题")
|
||||
if not is_puzzle_admin(target) and target.target_id != puzzle.author_id:
|
||||
return await target.send_message("你没有权限预览这个谜题")
|
||||
|
||||
msg = UniMessage.text(f"==== {puzzle.title} 提交记录 ====\n\n")
|
||||
submits = manager.submissions.get(raw_id, {})
|
||||
for uid, ls in submits.items():
|
||||
s = ', '.join((i.flag for i in ls))
|
||||
msg = msg.text(f"- {get_username(uid)}:{s}\n")
|
||||
return await target.send_message(msg)
|
||||
|
||||
|
||||
return cmd_admin
|
||||
|
||||
@ -394,7 +394,8 @@ async def generate_dice_image(number: str) -> BytesIO:
|
||||
append_images=images[1:],
|
||||
duration=frame_durations,
|
||||
format='GIF',
|
||||
loop=1)
|
||||
loop=1,
|
||||
disposal=2)
|
||||
output.seek(0)
|
||||
# pil_final.save(output, format='PNG')
|
||||
return output
|
||||
@ -4,10 +4,13 @@ from typing import cast
|
||||
|
||||
from loguru import logger
|
||||
from nonebot import get_bot, on_request
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11.event import FriendRequestEvent
|
||||
from nonebot.adapters.onebot.v11.bot import Bot as OnebotBot
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
|
||||
from konabot.common.nb.is_admin import cfg as adminConfig
|
||||
from konabot.common.username import manager
|
||||
|
||||
add_request = on_request()
|
||||
|
||||
@ -23,3 +26,15 @@ async def _(req: FriendRequestEvent):
|
||||
await req.approve(bot)
|
||||
logger.info(f"已经自动同意 {req.user_id} 的好友请求")
|
||||
|
||||
@scheduler.scheduled_job("cron", minute="*/5")
|
||||
async def _():
|
||||
logger.info("尝试更新群成员信息")
|
||||
await manager.update()
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
@driver.on_bot_connect
|
||||
async def _():
|
||||
logger.info("有 Bot 连接,5 秒后试着更新群成员信息")
|
||||
await asyncio.sleep(5)
|
||||
await manager.update()
|
||||
Reference in New Issue
Block a user