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,69 @@
from nonebot import on_command
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from .data_proc import *
from .img_generator import *
import io
import base64
__plugin_meta__ = PluginMetadata(
name="打卡记鹿",
description="你今天鹿管了ma?",
usage="at机器人 发送:打卡 "
"直接发送:查卡(查询打卡记录)",
type="application",
)
record = on_command("打卡记鹿", aliases={"打卡"}, rule=to_me())
query = on_command("查询记录", aliases={"查卡"})
@record.handle()
async def do_job(bot: Bot, ev: MessageEvent):
# 获取当前日期的年份和月份
now = datetime.now()
year = now.year
month = now.month
# 获取发信人信息
uid = str(ev.user_id)
message_id = ev.message_id
# 添加一条记录
await record_month(uid)
# 获取记录
img = await draw_calendar_grid_image(uid, year, month)
text_msg = MessageSegment.text("这是您的打卡记鹿:\n")
image_msg = MessageSegment.image(pic2b64(img))
mix_msg = (text_msg,)
mix_msg += image_msg
reply_msg = (MessageSegment.reply(message_id),)
reply_msg += mix_msg
await record.finish(reply_msg)
def pic2b64(pic: Image) -> str:
buf = io.BytesIO()
pic.save(buf, format='PNG')
base64_str = base64.b64encode(buf.getvalue()).decode()
return 'base64://' + base64_str
@query.handle()
async def query_job(bot: Bot, ev: MessageEvent):
# 获取当前日期的年份和月份
now = datetime.now()
year = now.year
month = now.month
# 获取发信人信息
uid = str(ev.user_id)
message_id = ev.message_id
# 获取记录
img_file = await draw_calendar_grid_image(uid, year, month)
text_msg = MessageSegment.text("这是您的记鹿:\n")
image_msg = MessageSegment.image(pic2b64(img_file))
mix_msg = (text_msg,)
mix_msg += image_msg
reply_msg = (MessageSegment.reply(message_id),)
reply_msg += mix_msg
await record.finish(reply_msg)
@@ -0,0 +1,47 @@
from datetime import datetime
from nonebot import require
require("nonebot_plugin_orm")
from nonebot_plugin_orm import Model, get_session
from sqlalchemy import BigInteger, Date, Integer, select
from sqlalchemy.orm import Mapped, mapped_column
class CheckInRecord(Model):
"""打卡记录,一行一次打卡,同一天可多次"""
__tablename__ = "deer_pipe_checkin"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uid: Mapped[int] = mapped_column(BigInteger, index=True)
date: Mapped[datetime.date] = mapped_column(Date, index=True)
# 记录信息
async def record_month(uid):
now = datetime.now()
async with get_session() as session:
session.add(CheckInRecord(uid=int(uid), date=now.date()))
await session.commit()
# 读取当月信息(返回当月所有打卡的日,同一天多次打卡会有重复项)
async def get_records(uid):
now = datetime.now()
month_start = datetime(now.year, now.month, 1).date()
if now.month == 12:
month_end = datetime(now.year + 1, 1, 1).date()
else:
month_end = datetime(now.year, now.month + 1, 1).date()
async with get_session() as session:
result = await session.execute(
select(CheckInRecord.date).where(
CheckInRecord.uid == int(uid),
CheckInRecord.date >= month_start,
CheckInRecord.date < month_end,
)
)
days = result.all()
return [row[0].day for row in days]
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,121 @@
import calendar
from collections import Counter
import os
from datetime import datetime, timedelta
from PIL import Image, ImageDraw, ImageFont, ImageOps
from .data_proc import *
import random
filepath = os.path.dirname(__file__).replace("\\", "/")
async def draw_calendar_grid_image(uid, year, month):
# 获取该月的第一天是星期几,以及这个月有多少天
first_weekday, num_days = calendar.monthrange(year, month)
# 调整第一天的索引,使周日对应0,周六对应6
first_weekday = (first_weekday + 1) % 7
# 设置单元格尺寸和内边距
cell_size = 50
cell_padding = 5
# 计算当前月份的行数
num_rows = (num_days + first_weekday + 6) // 7 + 1 # +1 行用于留空
# 计算图片宽度和高度
image_width = 7 * (cell_size + cell_padding) - cell_padding
image_height = num_rows * (cell_size + cell_padding) - cell_padding
# 创建图像对象
img = Image.new("RGBA", (image_width, image_height), "white")
draw = ImageDraw.Draw(img)
font_path = filepath + "/font/SourceHanSansCN-Medium.otf"
print(font_path)
font_normal = ImageFont.truetype(font=font_path, size=18)
font_large = ImageFont.truetype(font=font_path, size=24)
# 绘制网格线
for i in range(7):
x = i * (cell_size + cell_padding)
draw.line([(x, 55), (x, image_height)], fill="black", width=1)
for j in range(0, image_height + cell_size, cell_size + cell_padding):
draw.line([(0, j), (image_width, j)], fill="black", width=1)
# 小🦌
deer_pipe_path = filepath + "/img/deer_pipe.jpg"
deer_pipe_img = Image.open(deer_pipe_path)
deer_pipe_img = deer_pipe_img.resize((54, 50))
# 画标题
title = f"{month}月打卡记鹿"
text_length = draw.textlength(text=title, font=font_large)
img_xy = (int(((image_width - text_length + 55) / 2) - 55), 5)
title_pos = ((image_width - text_length + 55) / 2, 10)
img.paste(deer_pipe_img, img_xy)
draw.text(title_pos, title, fill="black", font=font_large)
# 获取🦌信息(同一天多次打卡会得到重复项)
day_counts = Counter(await get_records(uid))
# 填充日期
date = datetime(year, month, 1)
for day in range(1, num_days + 1):
weekday = (first_weekday + day - 1) % 7
row = (first_weekday + day - 1) // 7 + 1 # +1 行用于留空
x = weekday * (cell_size + cell_padding) + cell_padding
y = row * (cell_size + cell_padding) + cell_padding
img.paste(deer_pipe_img, (x - 4, y))
draw.text((x+20, y - 2), f"{str(day)}", fill="black", font=font_normal)
# 🦌是天的标记,打卡的PNG印记才是要多次绘制的对象,横向错开
for i in range(day_counts.get(day, 0)):
im = get_random_right()
img = image_paste(im, img, (x - 4 + i * 10, y))
# 保存图片
# img.save(output_image_path)
# 显示图片
# img.show()
img = ImageOps.expand(img, border=5, fill="black")
img = ImageOps.expand(img, border=5, fill="white")
return img
# 全局变量,用于存储已经选择过的文件名
selected_images = []
def get_random_right():
global selected_images
path = filepath + "/img/right"
# 过滤掉已经选择过的文件名
im_name = [name for name in os.listdir(path) if name not in selected_images]
# 如果所有文件都已经选择过,重新初始化已选择列表
if not im_name:
selected_images = []
im_name = os.listdir(path)
index = random.randint(0, len(im_name) - 1)
im = im_name[index]
selected_images.append(im) # 将选择的文件名添加到已选择列表中
im_path = os.path.join(path, im)
im_file = Image.open(im_path)
im_file = im_file.resize((50, 50))
return im_file
# 图片粘贴
def image_paste(paste_image, under_image, pos):
"""
:param paste_image: 需要粘贴的图片
:param under_image: 底图
:param pos: 位置(x,y)坐标
:return: 返回图片
"""
# 获取需要贴入图片的透明通道
r, g, b, alpha = paste_image.split()
# 粘贴时将alpha值传递至mask属性
under_image.paste(paste_image, pos, alpha)
return under_image