188 lines
6.9 KiB
Python
188 lines
6.9 KiB
Python
"""群聊人设卡 —— 基于群聊语料蒸馏成员形象,生成 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,
|
|||
|
|
)
|