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:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -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)