Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
import re
|
||||
from asyncio import sleep
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from nonebot import get_driver, on_message, on_startswith, require
|
||||
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, PrivateMessageEvent
|
||||
from nonebot.exception import ActionFailed
|
||||
from nonebot.log import logger
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.rule import is_type, to_me
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
from .config import (
|
||||
CHAIN_REPLY,
|
||||
CHECK,
|
||||
DAILY_LIMIT,
|
||||
IGNORE_STAMP,
|
||||
SAUCENAO_KEY,
|
||||
SEARCH_TIMEOUT,
|
||||
threshold,
|
||||
)
|
||||
from .image import check_screenshot, get_image_data_ascii, get_image_data_sauce
|
||||
|
||||
# 统一配置标准:把「插件内部常量」也暴露给 Web 管理台(来源无关)。
|
||||
# 说明:Web 读取/修改会写入 plugin_config.json 并热更新 config 模块属性;
|
||||
# 要在运行期立即生效,插件各使用点应改用 get_effective_value 读取(见 docs/plugin-config-standard.md §5b)。
|
||||
from hexi.config_standard import register_config_items # noqa: E402
|
||||
from . import config as _cfgmod # noqa: E402
|
||||
|
||||
register_config_items(
|
||||
__name__,
|
||||
[
|
||||
{"key": "SAUCENAO_KEY", "label": "SauceNAO API Key", "type": "password", "secret": True},
|
||||
{"key": "DAILY_LIMIT", "label": "每日搜图限额", "type": "int", "default": 50},
|
||||
{"key": "SEARCH_TIMEOUT", "label": "批量搜索超时(秒)", "type": "int", "default": 60},
|
||||
{"key": "CHECK", "label": "开启截屏判定", "type": "bool", "default": True},
|
||||
{"key": "THUMB_ON", "label": "启用缩略图", "type": "bool", "default": True},
|
||||
{"key": "CHAIN_REPLY", "label": "合并转发回复", "type": "bool", "default": True},
|
||||
{"key": "IGNORE_STAMP", "label": "批量忽略表情包", "type": "bool", "default": True},
|
||||
{"key": "threshold", "label": "相似度阈值", "type": "float", "default": 70},
|
||||
],
|
||||
store=_cfgmod,
|
||||
)
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="搜图",
|
||||
description="SauceNAO + ascii2d 二次元搜图",
|
||||
usage=(
|
||||
"(xx为bot名称)\n"
|
||||
"[@bot+图片] 单张/多张搜图\n"
|
||||
"[xx搜图] 进入批量搜图模式\n"
|
||||
"[谢谢xx] 退出批量搜图模式"
|
||||
),
|
||||
type="application",
|
||||
)
|
||||
|
||||
driver_config = get_driver().config
|
||||
SUPERUSERS = driver_config.superusers
|
||||
NICKNAMES = list(driver_config.nickname) if driver_config.nickname else ["竹竹"]
|
||||
NICKNAME = NICKNAMES[0]
|
||||
|
||||
class DailyNumberLimiter:
|
||||
"""每日次数限制器(按年积日计数)"""
|
||||
|
||||
def __init__(self, max_num):
|
||||
self.max = max_num
|
||||
self.today = -1
|
||||
self.count = {}
|
||||
|
||||
def check(self, key) -> bool:
|
||||
day = datetime.now().timetuple().tm_yday
|
||||
if day != self.today:
|
||||
self.today = day
|
||||
self.count.clear()
|
||||
return self.count.get(key, 0) < self.max
|
||||
|
||||
def get_num(self, key):
|
||||
return self.count.get(key, 0)
|
||||
|
||||
def increase(self, key, num=1):
|
||||
self.count[key] = self.count.get(key, 0) + num
|
||||
|
||||
|
||||
lmtd = DailyNumberLimiter(DAILY_LIMIT)
|
||||
|
||||
|
||||
class PicListener:
|
||||
def __init__(self):
|
||||
self.on = {}
|
||||
self.count = {}
|
||||
self.limit = {}
|
||||
self.timeout = {}
|
||||
|
||||
def get_on_off_status(self, gid):
|
||||
return self.on[gid] if self.on.get(gid) is not None else False
|
||||
|
||||
def turn_on(self, gid, uid):
|
||||
self.on[gid] = uid
|
||||
self.timeout[gid] = datetime.now() + timedelta(seconds=SEARCH_TIMEOUT)
|
||||
self.count[gid] = 0
|
||||
self.limit[gid] = DAILY_LIMIT - lmtd.get_num(uid)
|
||||
|
||||
def turn_off(self, gid):
|
||||
self.on.pop(gid)
|
||||
self.count.pop(gid)
|
||||
self.timeout.pop(gid)
|
||||
self.limit.pop(gid)
|
||||
|
||||
def count_plus(self, gid):
|
||||
self.count[gid] += 1
|
||||
|
||||
|
||||
pls = PicListener()
|
||||
|
||||
start_finder = on_startswith(("识图", "搜图", "查图", "找图"), rule=to_me())
|
||||
picmessage = on_message(rule=is_type(GroupMessageEvent), block=False)
|
||||
replymessage = on_message(rule=is_type(GroupMessageEvent), block=False)
|
||||
thanks = on_startswith("谢谢")
|
||||
pic_private = on_message(rule=is_type(PrivateMessageEvent), block=False)
|
||||
|
||||
|
||||
def parse_report(text: str) -> UniMessage:
|
||||
"""将含 [CQ:image,file=...] 的搜索报告字符串转为 UniMessage"""
|
||||
msg = UniMessage()
|
||||
parts = re.split(r"(\[CQ:image,[^\]]*\])", text)
|
||||
for part in parts:
|
||||
if part.startswith("[CQ:image,"):
|
||||
m = re.search(r"file=([^,\]]+)", part)
|
||||
msg += UniMessage.image(url=m.group(1) if m else "")
|
||||
elif part:
|
||||
msg += UniMessage.text(part)
|
||||
return msg
|
||||
|
||||
|
||||
def _extract_image(ev) -> tuple[str, str, str | None] | None:
|
||||
"""从消息中提取第一张图片的 (file, url, subType),无图片返回 None"""
|
||||
for m in ev.message:
|
||||
if m.type == "image":
|
||||
return m.data["file"], m.data["url"], m.data.get("subType")
|
||||
return None
|
||||
|
||||
|
||||
@start_finder.handle()
|
||||
async def start_finder_handle(bot: Bot, ev: GroupMessageEvent):
|
||||
uid = ev.user_id
|
||||
gid = ev.group_id
|
||||
mid = ev.message_id
|
||||
if str(uid) not in SUPERUSERS:
|
||||
await UniMessage.text("暂不支持在群聊中使用!").send()
|
||||
return
|
||||
ret = _extract_image(ev)
|
||||
if not ret:
|
||||
if pls.get_on_off_status(gid):
|
||||
if uid == pls.on[gid]:
|
||||
pls.timeout[gid] = datetime.now() + timedelta(seconds=30)
|
||||
await UniMessage.text(
|
||||
"您已经在搜图模式下啦!\n如想退出搜图模式请发送“谢谢竹竹”~"
|
||||
).send()
|
||||
await start_finder.finish()
|
||||
else:
|
||||
await UniMessage.at(user_id=str(pls.on[gid])).text(
|
||||
"正在搜图,请耐心等待~"
|
||||
).send()
|
||||
await start_finder.finish()
|
||||
pls.turn_on(gid, uid)
|
||||
await UniMessage.text(
|
||||
f"了解~请发送图片吧!支持批量噢!\n如想退出搜索模式请发送“谢谢{NICKNAME}”"
|
||||
).send()
|
||||
await sleep(30)
|
||||
ct = 0
|
||||
while pls.get_on_off_status(gid):
|
||||
if datetime.now() < pls.timeout[gid]:
|
||||
if ct != pls.count[gid]:
|
||||
ct = pls.count[gid]
|
||||
pls.timeout[gid] = datetime.now() + timedelta(seconds=60)
|
||||
else:
|
||||
temp = pls.on[gid]
|
||||
if not pls.count[gid]:
|
||||
await UniMessage.at(user_id=str(temp)).text(
|
||||
" 由于超时,已为您自动退出搜图模式~\n您本次搜索期间未发送任何图片,请检查是否被吞图~"
|
||||
).send()
|
||||
else:
|
||||
await UniMessage.at(user_id=str(temp)).text(
|
||||
f" 由于超时,已为您自动退出搜图模式,以后要记得说“谢谢{NICKNAME}”来退出搜图模式噢~\n您本次搜索共搜索了{pls.count[gid]}张图片~"
|
||||
).send()
|
||||
pls.turn_off(ev.group_id)
|
||||
break
|
||||
await sleep(30)
|
||||
return
|
||||
file, url, _ = ret
|
||||
if str(uid) not in SUPERUSERS:
|
||||
if not lmtd.check(uid):
|
||||
await UniMessage.text(
|
||||
f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~"
|
||||
).send(at_sender=True)
|
||||
return
|
||||
|
||||
if CHECK:
|
||||
result = await check_screenshot(bot, file, url)
|
||||
if result:
|
||||
if result == 1:
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图似乎是手机截屏,请进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~"
|
||||
).send()
|
||||
if result == 2:
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图似乎是长图拼接,请进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~"
|
||||
).send()
|
||||
return
|
||||
await UniMessage.text("正在搜索,请稍候~").send()
|
||||
await picfinder(bot, ev, url)
|
||||
|
||||
|
||||
@picmessage.handle()
|
||||
async def picmessage_handle(bot: Bot, ev: GroupMessageEvent):
|
||||
mid = ev.message_id
|
||||
atcheck = False
|
||||
batchcheck = False
|
||||
for m in ev.message:
|
||||
if m.type == "at" and int(m.data["qq"]) == ev.self_id:
|
||||
atcheck = True
|
||||
if pls.get_on_off_status(ev.group_id):
|
||||
if int(pls.on[ev.group_id]) == int(ev.user_id):
|
||||
batchcheck = True
|
||||
if not (batchcheck or atcheck):
|
||||
return
|
||||
uid = ev.user_id
|
||||
ret = _extract_image(ev)
|
||||
if not ret:
|
||||
return
|
||||
file, url, sbtype = ret
|
||||
if str(uid) not in SUPERUSERS:
|
||||
if not lmtd.check(uid):
|
||||
await UniMessage.text(
|
||||
f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~"
|
||||
).send(at_sender=True)
|
||||
if pls.get_on_off_status(ev.group_id):
|
||||
pls.turn_off(ev.group_id)
|
||||
return
|
||||
if pls.get_on_off_status(ev.group_id):
|
||||
pls.count_plus(ev.group_id)
|
||||
if pls.count[ev.group_id] > pls.limit[ev.group_id]:
|
||||
await UniMessage.text(
|
||||
f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~"
|
||||
).send(at_sender=True)
|
||||
pls.turn_off(ev.group_id)
|
||||
return
|
||||
if sbtype and IGNORE_STAMP:
|
||||
if sbtype != "0":
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图为表情,已忽略~如确需搜索请尝试单发搜索或回复搜索~"
|
||||
).send()
|
||||
return
|
||||
|
||||
if CHECK:
|
||||
result = await check_screenshot(bot, file, url)
|
||||
if result:
|
||||
if result == 1:
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图似乎是手机截屏,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~"
|
||||
).send()
|
||||
if result == 2:
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图似乎是长图拼接,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~"
|
||||
).send()
|
||||
return
|
||||
if "c2cpicdw.qpic.cn/offpic_new/" in url:
|
||||
md5 = file[:-6].upper()
|
||||
url = f"http://gchat.qpic.cn/gchatpic_new/0/0-0-{md5}/0?term=2"
|
||||
await UniMessage.text("正在搜索,请稍候~").send()
|
||||
await picfinder(bot, ev, url)
|
||||
|
||||
|
||||
@replymessage.handle()
|
||||
async def replymessage_handle(bot: Bot, ev: GroupMessageEvent):
|
||||
mid = ev.message_id
|
||||
uid = ev.user_id
|
||||
seg = ev.message[0]
|
||||
if seg.type != "reply":
|
||||
return
|
||||
tmid = seg.data["id"]
|
||||
cmd = ev.message.extract_plain_text()
|
||||
flag1 = 0
|
||||
flag2 = 0
|
||||
for m in ev.message[2:]:
|
||||
if m.type == "at" and int(m.data["qq"]) == ev.self_id:
|
||||
flag1 = 1
|
||||
for name in NICKNAMES:
|
||||
if name in cmd:
|
||||
flag1 = 1
|
||||
break
|
||||
for pfcmd in ["识图", "搜图", "查图", "找图"]:
|
||||
if pfcmd in cmd:
|
||||
flag2 = 1
|
||||
if not (flag1 and flag2):
|
||||
return
|
||||
if str(uid) not in SUPERUSERS:
|
||||
if not lmtd.check(uid):
|
||||
await UniMessage.text(
|
||||
f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~"
|
||||
).send(at_sender=True)
|
||||
try:
|
||||
tmsg = await bot.get_msg(message_id=int(tmid))
|
||||
except ActionFailed:
|
||||
await UniMessage.text("该消息已过期,请重新转发~").send()
|
||||
await replymessage.finish()
|
||||
ret = re.search(r"\[CQ:image,file=(.*)?,url=(.*)\]", str(tmsg["message"]))
|
||||
if not ret:
|
||||
await UniMessage.text("未找到图片~").send()
|
||||
return
|
||||
file = ret.group(1)
|
||||
url = ret.group(2)
|
||||
|
||||
if ",subType=" in url:
|
||||
url = url.split(",")[0]
|
||||
elif ",subType=" in file:
|
||||
file = file.split(",")[0]
|
||||
|
||||
if CHECK:
|
||||
result = await check_screenshot(bot, file, url)
|
||||
if result:
|
||||
if result == 1:
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图似乎是手机截屏,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~"
|
||||
).send()
|
||||
if result == 2:
|
||||
await UniMessage.reply(id=str(mid)).text(
|
||||
"该图似乎是长图拼接,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~"
|
||||
).send()
|
||||
return
|
||||
if "c2cpicdw.qpic.cn/offpic_new/" in url:
|
||||
md5 = file[:-6].upper()
|
||||
url = f"http://gchat.qpic.cn/gchatpic_new/0/0-0-{md5}/0?term=2"
|
||||
await UniMessage.text("正在搜索,请稍候~").send()
|
||||
await picfinder(bot, ev, url)
|
||||
|
||||
|
||||
@thanks.handle()
|
||||
async def thanks_handle(bot: Bot, ev: GroupMessageEvent):
|
||||
gid = ev.group_id
|
||||
name = ev.message.extract_plain_text().removeprefix("谢谢").strip()
|
||||
if name not in NICKNAMES:
|
||||
return
|
||||
if pls.get_on_off_status(gid):
|
||||
if pls.on[gid] != ev.user_id:
|
||||
await UniMessage.text("不能替别人结束搜图哦~").send()
|
||||
return
|
||||
if not pls.count[gid]:
|
||||
await UniMessage.text("不用谢~\n您本次搜索期间未发送任何图片,请检查是否被吞图~").send()
|
||||
else:
|
||||
await UniMessage.text(f"不用谢~\n您本次搜索共搜索了{pls.count[gid]}张图片~").send()
|
||||
pls.turn_off(gid)
|
||||
return
|
||||
await UniMessage.text("にゃ~").send()
|
||||
|
||||
|
||||
@pic_private.handle()
|
||||
async def pic_private_handle(bot: Bot, ev: PrivateMessageEvent):
|
||||
uid = ev.user_id
|
||||
ret = _extract_image(ev)
|
||||
if not ret:
|
||||
flag1 = flag2 = 0
|
||||
for name in NICKNAMES:
|
||||
if name in str(ev.message):
|
||||
flag1 = 1
|
||||
break
|
||||
for pfcmd in ["识图", "搜图", "查图", "找图"]:
|
||||
if pfcmd in str(ev.message):
|
||||
flag2 = 1
|
||||
if flag1 and flag2:
|
||||
await UniMessage.text("私聊搜图请直接发送图片~").send()
|
||||
return
|
||||
file, url, _ = ret
|
||||
if not lmtd.check(uid):
|
||||
await UniMessage.text(
|
||||
f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~"
|
||||
).send()
|
||||
return
|
||||
if "c2cpicdw.qpic.cn/offpic_new/" in url:
|
||||
md5 = file.upper()
|
||||
logger.info(f"图片URL{url}")
|
||||
url = f"https://gchat.qpic.cn/gchatpic_new/0/0-0-{md5}/0?term=2"
|
||||
|
||||
await UniMessage.text("正在搜索,请稍候~").send()
|
||||
result = await get_image_data_sauce(url, SAUCENAO_KEY)
|
||||
image_data_report = result[0]
|
||||
simimax = result[1]
|
||||
if "Index #" in image_data_report:
|
||||
su = next(iter(SUPERUSERS))
|
||||
await bot.send_private_msg(user_id=int(su), message="发生index解析错误")
|
||||
await bot.send_private_msg(user_id=int(su), message=url)
|
||||
await bot.send_private_msg(user_id=int(su), message=image_data_report)
|
||||
await parse_report(image_data_report).send()
|
||||
|
||||
if float(simimax) > float(threshold):
|
||||
lmtd.increase(uid)
|
||||
else:
|
||||
if simimax != 0:
|
||||
await UniMessage.text("相似度过低,换用ascii2d检索中…").send()
|
||||
else:
|
||||
logger.error("SauceNao not found imageInfo")
|
||||
await UniMessage.text("SauceNao检索失败,换用ascii2d检索中…").send()
|
||||
|
||||
image_data_report = await get_image_data_ascii(url)
|
||||
if image_data_report[0]:
|
||||
await parse_report(image_data_report[0]).send()
|
||||
lmtd.increase(uid)
|
||||
if image_data_report[1]:
|
||||
await parse_report(image_data_report[1]).send()
|
||||
if not (image_data_report[0] or image_data_report[1]):
|
||||
logger.error("ascii2d not found imageInfo")
|
||||
await UniMessage.text("ascii2d检索失败…").send()
|
||||
|
||||
|
||||
async def chain_reply(bot: Bot, ev: GroupMessageEvent, chain, msg):
|
||||
if not CHAIN_REPLY:
|
||||
await parse_report(msg).send()
|
||||
return chain
|
||||
data = {
|
||||
"type": "node",
|
||||
"data": {
|
||||
"name": str(NICKNAME) if str(NICKNAME) else "竹竹",
|
||||
"uin": str(ev.self_id),
|
||||
"content": str(msg),
|
||||
},
|
||||
}
|
||||
chain.append(data)
|
||||
return chain
|
||||
|
||||
|
||||
async def picfinder(bot: Bot, ev: GroupMessageEvent, image_data):
|
||||
uid = ev.user_id
|
||||
chain = []
|
||||
result = await get_image_data_sauce(image_data, SAUCENAO_KEY)
|
||||
image_data_report = result[0]
|
||||
simimax = result[1]
|
||||
if "Index #" in image_data_report:
|
||||
su = next(iter(SUPERUSERS))
|
||||
await bot.send_private_msg(user_id=int(su), message="发生index解析错误")
|
||||
await bot.send_private_msg(user_id=int(su), message=image_data)
|
||||
await bot.send_private_msg(user_id=int(su), message=image_data_report)
|
||||
chain = await chain_reply(bot, ev, chain, image_data_report)
|
||||
|
||||
if float(simimax) > float(threshold):
|
||||
lmtd.increase(uid)
|
||||
else:
|
||||
if simimax != 0:
|
||||
chain = await chain_reply(bot, ev, chain, "相似度过低,换用ascii2d检索中…")
|
||||
else:
|
||||
logger.error("SauceNao not found imageInfo")
|
||||
chain = await chain_reply(bot, ev, chain, "SauceNao检索失败,换用ascii2d检索中…")
|
||||
|
||||
image_data_report = await get_image_data_ascii(image_data)
|
||||
if image_data_report[0]:
|
||||
chain = await chain_reply(bot, ev, chain, image_data_report[0])
|
||||
lmtd.increase(uid)
|
||||
if image_data_report[1]:
|
||||
chain = await chain_reply(bot, ev, chain, image_data_report[1])
|
||||
if not (image_data_report[0] or image_data_report[1]):
|
||||
logger.error("ascii2d not found imageInfo")
|
||||
chain = await chain_reply(bot, ev, chain, "ascii2d检索失败…")
|
||||
|
||||
if CHAIN_REPLY:
|
||||
await bot.send_group_forward_msg(group_id=ev.group_id, messages=chain)
|
||||
Reference in New Issue
Block a user