Compare commits

...

12 Commits

Author SHA1 Message Date
4305548ab5 submodule
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 22:53:44 +08:00
99382a3bf5 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 22:48:17 +08:00
92e43785bf submodule 2025-10-13 22:46:30 +08:00
fc5b11c5e8 调整 notify 的强制退出
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 22:16:50 +08:00
0ec66988fa 更新投票存储位置
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 22:05:21 +08:00
e5c3081c22 Merge branch 'master' of https://gitea.service.jazzwhom.top/mttu-developers/konabot
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-13 22:02:44 +08:00
14b356120a 成语接龙 2025-10-13 22:02:33 +08:00
a208302cb9 添加依赖
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-13 21:35:44 +08:00
01ffa451bb Merge pull request '投票功能和二维码生成(从 testpilot 移植)' (#26) from wzq02/konabot:master into master
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #26
2025-10-13 21:33:03 +08:00
2b6c2e84bd Merge branch 'master' into master 2025-10-13 21:31:40 +08:00
4c8625ae02 小完善(添加对应的 man) 2025-10-13 21:08:32 +08:00
c5f820a1f9 投票功能和二维码生成(从 testpilot 移植) 2025-10-13 20:49:56 +08:00
14 changed files with 360751 additions and 12 deletions

View File

@ -10,6 +10,10 @@ trigger:
- master - master
steps: steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --init --recursive
- name: 构建 Docker 镜像 - name: 构建 Docker 镜像
image: plugins/docker:latest image: plugins/docker:latest
privileged: true privileged: true
@ -50,6 +54,10 @@ trigger:
- tag - tag
steps: steps:
- name: submodules
image: alpine/git
commands:
- git submodule update --init --recursive
- name: 构建并推送 Release Docker 镜像 - name: 构建并推送 Release Docker 镜像
image: plugins/docker:latest image: plugins/docker:latest
privileged: true privileged: true

1
assets/json/poll.json Normal file
View File

@ -0,0 +1 @@
{"poll": {"0": {"create": 1760357553, "expiry": 1760443953, "options": {"0": "此方bot", "1": "testpilot", "2": "小镜bot", "3": "可怜bot"}, "polldata": {}, "qq": "2975499623", "title": "我~是~谁~"}}}

360393
assets/lexicon/common.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,3 +12,10 @@ DOCS_PATH_MAN1 = DOCS_PATH / "user"
DOCS_PATH_MAN3 = DOCS_PATH / "lib" DOCS_PATH_MAN3 = DOCS_PATH / "lib"
DOCS_PATH_MAN7 = DOCS_PATH / "concepts" DOCS_PATH_MAN7 = DOCS_PATH / "concepts"
DOCS_PATH_MAN8 = DOCS_PATH / "sys" DOCS_PATH_MAN8 = DOCS_PATH / "sys"
if not DATA_PATH.exists():
DATA_PATH.mkdir()
if not LOG_PATH.exists():
LOG_PATH.mkdir()

View File

@ -0,0 +1,11 @@
指令介绍
发起投票 - 发起一个投票
格式
发起投票 <投票标题> <选项1> <选项2> ...
示例
`发起投票 这是一个投票 A B C` 发起标题为“这是一个投票”选项为“A”、“B”、“C”的投票
说明
投票各个选项之间用空格分隔选项数量为2-15项。投票的默认有效期为24小时。

View File

@ -0,0 +1,12 @@
指令介绍
投票 - 参与已发起的投票
格式
投票 <投票ID/标题> <选项文本>
示例
`投票 1 A` 在ID为1的投票中投给“A”
`投票 这是一个投票 B` 在标题为“这是一个投票”的投票中投给“B”
说明
目前不支持单人多投,每个人只能投一项。

View File

@ -0,0 +1,12 @@
指令介绍
查看投票 - 查看已发起的投票
格式
查看投票 <投票ID或标题>
示例
`查看投票 1` 查看ID为1的投票
`查看投票 这是一个投票` 查看标题为“这是一个投票”的投票
说明
投票在进行时,使用此命令可以看到投票的各个选项;投票结束后,则可以看到各项的票数。

View File

@ -0,0 +1,8 @@
指令介绍
生成二维码 - 将文本内容转换为二维码
格式
生成二维码 <文本内容>
示例
`生成二维码 嗨嗨嗨` 生成扫描结果为“嗨嗨嗨”的二维码图片

View File

@ -0,0 +1,69 @@
import qrcode
# from pyzbar.pyzbar import decode
# from PIL import Image
import requests
from io import BytesIO
from nonebot_plugin_alconna import (Alconna, Args, Field, MultiVar, UniMessage,
on_alconna)
from nonebot_plugin_alconna.uniseg import UniMsg, At, Reply
async def download_img(url):
resp = requests.get(url.replace("https://multimedia.nt.qq","http://multimedia.nt.qq")) # bim获取QQ的图片时避免SSLv3报错
img_bytes = BytesIO()
with open(img_bytes,"wb") as f:
f.write(resp.content)
return img_bytes
def genqr(data):
qr = qrcode.QRCode(version=1,error_correction=qrcode.constants.ERROR_CORRECT_L,box_size=8,border=4)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
return img_bytes
"""
async def recqr(url):
im_path = "assets/img/qrcode/2.jpg"
data = await download_img(url)
img = Image.open(im_path)
decoded_objects = decode(img)
data = ""
for obj in decoded_objects:
data += obj.data.decode('utf-8')
return data
"""
gqrc = on_alconna(Alconna(
"genqr",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "请输入你要转换为二维码的文字!"
)],
# UniMessage[]
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"生成二维码","genqrcode"})
@gqrc.handle()
async def _(saying: list):
"""
img = await draw_pt("\n".join(saying))
img_bytes = BytesIO()
img.save(img_bytes, format="PNG")
await pt.send(await UniMessage().image(raw=img_bytes).export())
# print(saying)
# 二维码识别
if type(saying[0]) == 'image':
data = await recqr(saying[0].data['url'])
if data == "":
await gqrc.send("二维码图片解析失败!")
else:
await gqrc.send(recqr(saying[0].data['url']))
# 二维码生成
else:
"""
# genqr("\n".join(saying))
await gqrc.send(await UniMessage().image(raw=genqr("\n".join(saying))).export())

View File

@ -27,6 +27,14 @@ def init_lexicon():
# 词语大表 # 词语大表
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f: with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
ALL_WORDS = json.load(f) ALL_WORDS = json.load(f)
COMMON_WORDS = []
# 读取 COMMON 词语大表
with open(ASSETS_PATH / "lexicon" / "common.txt", "r", encoding="utf-8") as f:
for line in f:
word = line.strip()
if len(word) == 4:
COMMON_WORDS.append(word)
# 读取 THUOCL 成语库 # 读取 THUOCL 成语库
with open(ASSETS_PATH / "lexicon" / "THUOCL" / "data" / "THUOCL_chengyu.txt", "r", encoding="utf-8") as f: with open(ASSETS_PATH / "lexicon" / "THUOCL" / "data" / "THUOCL_chengyu.txt", "r", encoding="utf-8") as f:
@ -49,7 +57,7 @@ def init_lexicon():
ALL_IDIOMS = list(set(ALL_IDIOMS)) # 去重 ALL_IDIOMS = list(set(ALL_IDIOMS)) # 去重
# 其他四字词语表,仅表示可以有这个词 # 其他四字词语表,仅表示可以有这个词
ALL_WORDS = [word for word in ALL_WORDS if len(word) == 4] + THUOCL_WORDS ALL_WORDS = [word for word in ALL_WORDS if len(word) == 4] + THUOCL_WORDS + COMMON_WORDS
ALL_WORDS = list(set(ALL_WORDS)) # 去重 ALL_WORDS = list(set(ALL_WORDS)) # 去重
# 根据成语大表,划分出成语首字字典 # 根据成语大表,划分出成语首字字典
@ -109,7 +117,7 @@ async def _(event: BaseEvent):
# 按分数排序,名字用 at 的方式 # 按分数排序,名字用 at 的方式
sorted_score = sorted(SCORE_BOARD.items(), key=lambda x: x[1]["score"], reverse=True) sorted_score = sorted(SCORE_BOARD.items(), key=lambda x: x[1]["score"], reverse=True)
for i, (user_id, info) in enumerate(sorted_score): for i, (user_id, info) in enumerate(sorted_score):
result_text += f"{i+1}. " + UniMessage().at(user_id) + f" - {info['score']}\n" result_text += f"{i+1}. " + UniMessage().at(user_id) + f": {info['score']}\n"
await evt.send(await result_text.export()) await evt.send(await result_text.export())
# 重置分数板 # 重置分数板
SCORE_BOARD = {} SCORE_BOARD = {}

View File

@ -0,0 +1,166 @@
import json, time
from nonebot_plugin_alconna import Alconna, Args, Field, MultiVar, on_alconna
from nonebot.adapters.onebot.v11 import Event
from konabot.common.path import ASSETS_PATH, DATA_PATH
POLL_TEMPLATE_FILE = ASSETS_PATH / "json" / "poll.json"
POLL_DATA_FILE = DATA_PATH / "poll.json"
if not POLL_DATA_FILE.exists():
POLL_DATA_FILE.write_bytes(POLL_TEMPLATE_FILE.read_bytes())
poll_list = json.loads(POLL_DATA_FILE.read_text())['poll']
async def createpoll(title,qqid,options):
polllength = len(poll_list)
pollid = str(polllength)
poll_create = int(time.time())
poll_expiry = poll_create + 24*3600
polljson = {"title":title,"qq":qqid,"create":poll_create,"expiry":poll_expiry,"options":options,"polldata":{}}
poll_list[pollid] = polljson
writeback()
return pollid
def getpolldata(pollid_or_title):
# 初始化“被指定的投票项目”
thepoll = {}
polnum = -1
# 判断是ID还是标题
if str.isdigit(pollid_or_title):
if pollid_or_title in poll_list:
thepoll = poll_list[pollid_or_title]
polnum = pollid_or_title
else:
return [{},-1]
else:
for i in poll_list:
if poll_list[i]["title"] == pollid_or_title:
thepoll = poll_list[i]
polnum = i
break
if polnum == -1:
return [{},-1]
return [thepoll,polnum]
def writeback():
# file = open(poll_json_path,"w",encoding="utf-8")
# json.dump({'poll':poll_list},file,ensure_ascii=False,sort_keys=True)
POLL_DATA_FILE.write_text(json.dumps({
'poll': poll_list,
}, ensure_ascii=False, sort_keys=True))
async def pollvote(polnum,optionnum,qqnum):
optiond = poll_list[polnum]["polldata"]
if optionnum not in optiond:
poll_list[polnum]["polldata"][optionnum] = []
poll_list[polnum]["polldata"][optionnum].append(qqnum)
writeback()
return
poll = on_alconna(Alconna(
"poll",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "参数错误。用法:发起投票 <投票标题> <选项1> <选项2> ..."
)],
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"发起投票","createpoll"})
@poll.handle()
async def _(saying: list, event: Event):
if (len(saying) < 3):
await poll.send("请提供至少两个投票选项!")
elif (len(saying) < 17):
title = saying[0]
saying.remove(title)
options = {}
for i in saying:
options[saying.index(i)] = i
qqid = event.get_user_id()
result = await createpoll(title,qqid,options)
await poll.send("已创建投票。回复 查看投票 "+str(result)+" 查看该投票。")
else:
await poll.send("投票选项太多了请减少到15个选项以内。")
viewpoll = on_alconna(Alconna(
"viewpoll",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "请指定投票ID或标题。用法查看投票 <投票ID或标题>"
)],
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"查看投票"})
@viewpoll.handle()
async def _(saying: list):
# 参数投票ID或者标题
# pollid_or_title = params[0]
polldata = getpolldata(saying[0])
# 被指定的投票项目
thepoll = polldata[0]
polnum = polldata[1]
if polnum == -1:
await viewpoll.send("该投票不存在!")
else:
# 检查投票是否已结束
pollended = 0
if time.time() > thepoll["expiry"]:
pollended = 1
# 回复内容
reply = "投票:"+thepoll["title"]+" [ID: "+str(polnum)+"]"
# 如果投票已结束
if pollended:
for i in thepoll["options"]:
reply += "\n"
# 检查该选项是否有人投票
if i in thepoll["polldata"]:
reply += "["+str(len(thepoll["polldata"][i]))+" 票]"
else:
reply += "[0 票]"
reply += " "+thepoll["options"][i]
reply += "\n\n此投票已结束。"
else:
for i in thepoll["options"]:
reply += "\n"
reply += "- "+thepoll["options"][i]
# reply += "\n\n小提示向bot私聊发送 /viewpoll "+str(polnum)+" 可查看已投票数哦!"
reply += "\n\n发送 投票 "+str(polnum)+" <选项文本> 即可参与投票!"
await viewpoll.send(reply)
vote = on_alconna(Alconna(
"vote",
Args["saying", MultiVar(str, '+'), Field(
missing_tips=lambda: "参数错误。用法:投票 <投票ID/标题> <选项文本>"
)],
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=False, aliases={"投票","参与投票"})
@vote.handle()
async def _(saying: list, event: Event):
if (len(saying) < 2):
await vote.send("请指定投给哪一项!")
else:
polldata = getpolldata(saying[0])
# 被指定的投票项目
thepoll = polldata[0]
polnum = polldata[1]
if polnum == -1:
await viewpoll.finish("没有找到这个投票!")
# thepolldata = thepoll["polldata"]
# 查找对应的投票项
optionnum = -1
for i in thepoll["options"]:
if saying[1] == thepoll["options"][i]:
optionnum = i
break
if optionnum == -1:
reply = "此投票里面没有这一项!可用的选项有:"
for i in thepoll["options"]:
reply += "\n"
reply += "- "+thepoll["options"][i]
await viewpoll.send(reply)
# 检查是否符合投票条件该qq号是否已参与过投票、投票是否过期
elif time.time() > thepoll["expiry"]:
await viewpoll.send("此投票已经结束!请发送 查看投票 "+polnum+" 查看结果。")
elif str(event.get_user_id()) in str(thepoll["polldata"]):
await viewpoll.send("你已参与过此投票!请在投票结束后发送 查看投票 "+polnum+" 查看结果。")
# 写入项目
else:
await pollvote(polnum,optionnum,event.get_user_id())
await viewpoll.send("投票成功!你投给了 "+saying[1])

View File

@ -1,8 +1,10 @@
import asyncio import asyncio as asynkio
import datetime import datetime
import functools
from pathlib import Path from pathlib import Path
from typing import Any, Literal, cast from typing import Any, Literal, cast
import signal
import nonebot import nonebot
import ptimeparse import ptimeparse
from loguru import logger from loguru import logger
@ -24,7 +26,9 @@ evt = on_message()
(Path(__file__).parent.parent.parent.parent / "data").mkdir(exist_ok=True) (Path(__file__).parent.parent.parent.parent / "data").mkdir(exist_ok=True)
DATA_FILE_PATH = Path(__file__).parent.parent.parent.parent / "data" / "notify.json" DATA_FILE_PATH = Path(__file__).parent.parent.parent.parent / "data" / "notify.json"
DATA_FILE_LOCK = asyncio.Lock() DATA_FILE_LOCK = asynkio.Lock()
ASYNK_TASKS: set[asynkio.Task[Any]] = set()
class Notify(BaseModel): class Notify(BaseModel):
@ -111,7 +115,11 @@ def create_notify_task(notify: Notify, fail2remove: bool = True):
async def mission(): async def mission():
begin_time = datetime.datetime.now() begin_time = datetime.datetime.now()
if begin_time < notify.notify_time: if begin_time < notify.notify_time:
await asyncio.sleep((notify.notify_time - begin_time).total_seconds()) try:
await asynkio.sleep((notify.notify_time - begin_time).total_seconds())
except asynkio.CancelledError:
logger.debug("代办提醒被信号中止,任务退出")
return
else: else:
logger.warning( logger.warning(
f"期望在 {notify.notify_time} 在平台 {notify.platform} {notify.target_env}" f"期望在 {notify.notify_time} 在平台 {notify.platform} {notify.target_env}"
@ -128,7 +136,7 @@ def create_notify_task(notify: Notify, fail2remove: bool = True):
DATA_FILE_LOCK.release() DATA_FILE_LOCK.release()
else: else:
pass pass
return asyncio.create_task(mission()) return asynkio.create_task(mission())
@evt.handle() @evt.handle()
@ -214,11 +222,11 @@ async def _():
DELTA = 2 DELTA = 2
logger.info(f"第一次探测到 Bot 连接,等待 {DELTA} 秒后开始通知") logger.info(f"第一次探测到 Bot 连接,等待 {DELTA} 秒后开始通知")
await asyncio.sleep(DELTA) await asynkio.sleep(DELTA)
await DATA_FILE_LOCK.acquire() await DATA_FILE_LOCK.acquire()
tasks: set[asyncio.Task[Any]] = set() # tasks: set[asynkio.Task[Any]] = set()
cfg = load_notify_config() cfg = load_notify_config()
if cfg.version == 1: if cfg.version == 1:
logger.info("将配置文件的版本升级为 2") logger.info("将配置文件的版本升级为 2")
@ -227,11 +235,26 @@ async def _():
counter = 0 counter = 0
for notify in [*cfg.notifies]: for notify in [*cfg.notifies]:
task = create_notify_task(notify, fail2remove=False) task = create_notify_task(notify, fail2remove=False)
tasks.add(task) ASYNK_TASKS.add(task)
task.add_done_callback(lambda self: tasks.remove(self)) task.add_done_callback(lambda self: ASYNK_TASKS.remove(self))
counter += 1 counter += 1
logger.info(f"成功创建了 {counter} 条代办事项") logger.info(f"成功创建了 {counter} 条代办事项")
save_notify_config(cfg) save_notify_config(cfg)
DATA_FILE_LOCK.release() DATA_FILE_LOCK.release()
await asyncio.gather(*tasks) loop = asynkio.get_running_loop()
# 解决 asynk task 没有被 cancel 的问题
async def shutdown(sig: signal.Signals):
logger.info(f"收到 {sig.name} 指令,正在关闭所有的东西")
for task in ASYNK_TASKS:
task.cancel()
await asynkio.gather(*ASYNK_TASKS, return_exceptions=True)
logger.info("所有的代办提醒 Task 都已经退出了")
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, functools.partial(
asynkio.create_task, shutdown(sig)
))
await asynkio.gather(*ASYNK_TASKS)

22
poetry.lock generated
View File

@ -2518,6 +2518,26 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
] ]
[[package]]
name = "qrcode"
version = "8.2"
description = "QR Code image generator"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f"},
{file = "qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
all = ["pillow (>=9.1.0)", "pypng"]
pil = ["pillow (>=9.1.0)"]
png = ["pypng"]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@ -3325,4 +3345,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.12,<4.0" python-versions = ">=3.12,<4.0"
content-hash = "013942994a91012f285305194d73b6609fe71bbd214a63dce443877853dcd764" content-hash = "6fc63a138508a779d47346e0186b4c771ed17b10f278a0e094c6994c1d99a877"

View File

@ -25,6 +25,7 @@ dependencies = [
"ptimeparse (>=0.1.1,<0.2.0)", "ptimeparse (>=0.1.1,<0.2.0)",
"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)",
] ]
[build-system] [build-system]