Files
HeXi/hexi/plugins/nonebot_plugin_galgame_card/__init__.py
T
sansenhoshiandClaude b61d09f09f 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>
2026-09-01 13:13:40 +08:00

188 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""群聊人设卡 —— 基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡
消息入口:on_message 监听全部消息事件,类型注解过滤出群聊,
两级闸门(群开关 + 个人 opt-in)通过后,治理层处理落库。
当前进度:数据层 + 采集层(消息路径)完成;总结路径(印象/画像)待实现。
"""
import json
import time
from collections import deque
from typing import Optional
from nonebot import get_driver, on_message, require
require("nonebot_plugin_orm")
from nonebot.adapters.onebot.v11 import GroupMessageEvent
from nonebot.plugin import PluginMetadata
# ── 禁用 nonebot_plugin_orm 的启动自动同步 ─────────────────────────────
# .env 里 ALEMBIC_STARTUP_CHECK=false 时,orm 插件每次启动都会 autogenerate
# 同步数据库模式;模型一有变更(改 models.py)就会清空整表数据(已实测踩坑)。
# 本插件的 schema 演进统一走 repository.ensure_schema() 显式迁移,
# 因此把 migrate.sync 替换为安全空操作。改动必须在插件导入期完成
# (早于 orm 的 on_startup 钩子执行)。
from nonebot import logger as _logger
try:
import nonebot_plugin_orm.migrate as _orm_migrate
def _safe_sync(*_args, **_kwargs):
# 同步函数:orm 用 greenlet_spawn 同步调用 migrate.sync,
# 若写成 async def 会产生 never-awaited coroutine 警告
_logger.debug("galgame_card | 已禁用 orm 自动同步(schema 演进由 ensure_schema 接管)")
_orm_migrate.sync = _safe_sync
except ImportError:
_logger.warning("galgame_card | 无法禁用 orm 自动同步,请检查 nonebot_plugin_orm 是否安装")
from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
from hexi.web_config import register_plugin_config # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from . import web_hub # noqa: E402
def _apply_galgame_config(values: dict) -> None:
"""Web 保存后热刷新:直接改 config 模块的 pydantic 对象,无需重启。"""
import sys
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
if conf_mod is None:
return
conf = getattr(conf_mod, "config", None)
if conf is None:
return
for key, value in values.items():
try:
setattr(conf, key, value)
except Exception: # noqa: BLE001
pass
def _get_galgame_config_values() -> dict:
"""Web 表单回填当前生效值。"""
import sys
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
conf = getattr(conf_mod, "config", None) if conf_mod else None
if conf is None:
return {}
keys = ("web_enable", "web_username", "web_password", "web_secret_key", "image_vision_enabled")
return {k: getattr(conf, k, None) for k in keys}
register_plugin_config(
__name__,
{
"fields": [
{"key": "web_enable", "label": "Web 后台启用", "type": "bool", "default": True},
{"key": "web_username", "label": "Web 登录账号", "type": "string", "default": "admin"},
{"key": "web_password", "label": "Web 登录密码", "type": "password", "default": "galgame", "secret": True},
{"key": "web_secret_key", "label": "Web 签名密钥", "type": "password", "default": "galgame-card-secret-key", "secret": True},
{"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
]
},
apply=_apply_galgame_config,
getter=_get_galgame_config_values,
)
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/galgame_card
register_web_plugin(
"galgame_card",
"群聊人设卡",
"user",
lambda: web_hub.build_admin_app(),
module_name=__name__,
)
__plugin_meta__ = PluginMetadata(
name="群聊人设卡",
description="基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡",
usage="开发中……",
)
# 采样/复读检测的内存状态(不进库)
_recent_texts: dict[int, deque[str]] = {} # group_id -> 最近消息文本
_last_store: dict[tuple[int, int], float] = {} # (group_id, user_id) -> 上次落库时间戳
def _is_repeat(group_id: int, content: str) -> bool:
"""复读检测:内容在最近 N 条里出现过则丢弃"""
queue = _recent_texts.setdefault(group_id, deque(maxlen=processor.REPEAT_WINDOW))
if content in queue:
return True
queue.append(content)
return False
def _throttled(group_id: int, user_id: int) -> bool:
"""刷屏采样:5 秒内同一个人只落库一次"""
key = (group_id, user_id)
now = time.time()
if now - _last_store.get(key, 0) < processor.THROTTLE_SECONDS:
return True
_last_store[key] = now
return False
_driver = get_driver()
@_driver.on_startup
async def _ensure_schema():
"""老库迁移(persona_group.group_name 列),幂等"""
await repository.ensure_schema()
collector = on_message(block=False) # 监听全部消息事件,不拦截其他插件
@collector.handle()
async def collect(event: GroupMessageEvent):
# 鉴权:两级闸门(群开关 → 个人 opt-in)
if not await repository.is_group_enabled(event.group_id):
return
if not await repository.is_joined(event.user_id, event.group_id):
return
# 机器人自己(如转发)不收
if event.user_id == event.self_id:
return
command_starts = get_driver().config.command_start
# 治理:文本 / 图片 / 表情任一存在才记录
if not processor.has_recordable_content(event):
return
text = processor.filter_content(
processor.extract_text(event), command_starts=command_starts
)
image_hashes = processor.extract_image_hashes(event)
content = processor.build_content(
text or "", len(image_hashes), processor.count_faces(event)
)
if content is None:
return
# 脱敏:敏感值替换为占位符(语境 = 本群最近几条已治理文本,用于"验证码/密码"类强化)
context = " ".join(_recent_texts.get(event.group_id, ()))
content = processor.desensitize(content, context)
# 采样:复读 + 刷屏
if _is_repeat(event.group_id, content):
return
if _throttled(event.group_id, event.user_id):
return
await repository.add_chat_log(
user_id=event.user_id,
group_id=event.group_id,
content=content,
nickname=event.sender.card or event.sender.nickname or "",
target_user_id=processor.extract_target_user_id(event),
reply_to_content=processor.extract_reply_to_content(event),
image_count=len(image_hashes),
image_hashes=json.dumps(image_hashes, ensure_ascii=False) if image_hashes else None,
)