""" 群昵称变动通知插件 检测群成员群昵称变更并发送卡片通知。 """ from __future__ import annotations import asyncio import base64 import html import json from pathlib import Path from typing import Dict, List, Optional import aiohttp import aiosqlite import tomllib from loguru import logger from utils.decorators import on_chatroom_member_nickname_change from utils.plugin_base import PluginBase from WechatHook import WechatHookClient HTML_RENDERER_AVAILABLE = False try: from plugins.SignInPlugin.html_renderer import HtmlRenderer HTML_RENDERER_AVAILABLE = True except Exception: logger.warning("GroupNicknameNotify: HTML 渲染器导入失败,将无法生成图片") class GroupNicknameNotify(PluginBase): """群成员昵称变动通知插件""" description = "群成员昵称变更时发送卡片通知" author = "Codex" version = "1.0.0" def __init__(self): super().__init__() self.config: Dict = {} self.enabled_groups: List[str] = [] self.disabled_groups: List[str] = [] self.preload_cache = False self.cleanup_image = True self.render_timeout = 12 self.avatar_timeout = 4 self.use_member_sync_db = True self.cache: Dict[str, Dict[str, str]] = {} self.member_sync_db_path: Optional[Path] = None base_dir = Path(__file__).parent self.temp_dir = base_dir / "temp" self.templates_dir = base_dir / "templates" self.images_dir = base_dir / "images" self.cache_path = base_dir / "cache.json" self.html_renderer: Optional[HtmlRenderer] = None async def async_init(self): """异步初始化""" config_path = Path(__file__).parent / "config.toml" if config_path.exists(): with open(config_path, "rb") as f: self.config = tomllib.load(f) behavior = self.config.get("behavior", {}) self.enabled_groups = behavior.get("enabled_groups", []) self.disabled_groups = behavior.get("disabled_groups", []) self.preload_cache = bool(behavior.get("preload_cache", False)) data_cfg = self.config.get("data", {}) self.use_member_sync_db = bool(data_cfg.get("use_member_sync_db", True)) render_cfg = self.config.get("render", {}) self.cleanup_image = bool(render_cfg.get("cleanup_image", True)) use_html = bool(render_cfg.get("use_html", True)) self.render_timeout = int(render_cfg.get("render_timeout", self.render_timeout)) self.avatar_timeout = int(render_cfg.get("avatar_timeout", self.avatar_timeout)) self.temp_dir.mkdir(exist_ok=True) self.templates_dir.mkdir(exist_ok=True) self.images_dir.mkdir(exist_ok=True) self._load_cache() self._resolve_member_sync_db_path() if use_html and HTML_RENDERER_AVAILABLE: self.html_renderer = HtmlRenderer( template_dir=self.templates_dir, output_dir=self.temp_dir, images_dir=self.images_dir, bg_source=render_cfg.get("bg_source", "local"), bg_api_url=render_cfg.get("bg_api_url", ""), ) logger.info("GroupNicknameNotify: HTML 渲染已启用") else: self.html_renderer = None logger.warning("GroupNicknameNotify: HTML 渲染不可用") logger.success("GroupNicknameNotify 插件初始化完成") async def on_enable(self, bot=None): await super().on_enable(bot) if self.preload_cache and bot: await self._refresh_cache(bot) def _load_cache(self): if not self.cache_path.exists(): return try: raw = self.cache_path.read_text(encoding="utf-8") data = json.loads(raw) if raw else {} if isinstance(data, dict): self.cache = data except Exception as e: logger.warning(f"GroupNicknameNotify: 读取缓存失败: {e}") def _save_cache(self): try: self.cache_path.write_text(json.dumps(self.cache, ensure_ascii=False), encoding="utf-8") except Exception as e: logger.warning(f"GroupNicknameNotify: 保存缓存失败: {e}") def _resolve_member_sync_db_path(self): if not self.use_member_sync_db: return plugins_dir = Path(__file__).parent.parent config_path = plugins_dir / "MemberSync" / "config.toml" if not config_path.exists(): return try: with open(config_path, "rb") as f: cfg = tomllib.load(f) db_rel = str(cfg.get("database", {}).get("db_path", "")).strip() if not db_rel: return db_path = plugins_dir / "MemberSync" / db_rel if db_path.exists(): self.member_sync_db_path = db_path except Exception as e: logger.debug(f"GroupNicknameNotify: 解析 MemberSync 配置失败: {e}") async def _get_member_sync_avatar(self, room_wxid: str, wxid: str) -> str: if not self.use_member_sync_db or not self.member_sync_db_path: return "" try: async with aiosqlite.connect(self.member_sync_db_path) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( """SELECT avatar_url FROM group_members WHERE chatroom_wxid = ? AND wxid = ? ORDER BY updated_at DESC LIMIT 1""", (room_wxid, wxid), ) row = await cursor.fetchone() if not row: return "" return row["avatar_url"] or "" except Exception as e: logger.debug(f"GroupNicknameNotify: 查询 MemberSync 失败: {e}") return "" async def _get_member_sync_group_nickname(self, room_wxid: str, wxid: str) -> str: if not self.use_member_sync_db or not self.member_sync_db_path: return "" try: async with aiosqlite.connect(self.member_sync_db_path) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( """SELECT group_nickname, nickname FROM group_members WHERE chatroom_wxid = ? AND wxid = ? ORDER BY updated_at DESC LIMIT 1""", (room_wxid, wxid), ) row = await cursor.fetchone() if not row: return "" group_nickname = row["group_nickname"] or "" if group_nickname: return group_nickname return row["nickname"] or "" except Exception as e: logger.debug(f"GroupNicknameNotify: 查询 MemberSync 群昵称失败: {e}") return "" def _should_notify(self, room_wxid: str) -> bool: if room_wxid in self.disabled_groups: return False if not self.enabled_groups: return True return room_wxid in self.enabled_groups def _normalize_display_name(self, member: dict) -> str: display_name = self._clean_name(member.get("display_name") or "") nickname = self._clean_name(member.get("nickname") or "") name = display_name or nickname if not name: name = str(member.get("wxid") or "") return name def _clean_name(self, name: str) -> str: if not name: return "" return str(name).strip() def _truncate_text(self, text: str, max_len: int = 14) -> str: text = text or "" if len(text) <= max_len: return text return text[: max_len - 1] + "…" def _build_avatar_html(self, avatar_url: str, fallback_text: str) -> str: if avatar_url: safe_url = html.escape(avatar_url, quote=True) return f'avatar' fallback = html.escape((fallback_text or "?")[:1]) return f'
{fallback}
' def _build_card_html(self, member_name: str, old_name: str, new_name: str, avatar_url: str) -> str: member_name = html.escape(self._truncate_text(member_name, 10)) old_name = html.escape(self._truncate_text(old_name, 14)) new_name = html.escape(self._truncate_text(new_name, 14)) avatar_html = self._build_avatar_html(avatar_url, member_name) return f"""
群成员 {member_name} 将名称 {old_name} 更改为 {new_name}
{avatar_html}
{old_name}
{avatar_html}
{new_name}
""" async def _render_card(self, html_content: str) -> Optional[str]: if not self.html_renderer: return None try: return await asyncio.wait_for( self.html_renderer._render_html(html_content, "nickname_change"), timeout=max(3, int(self.render_timeout)), ) except asyncio.TimeoutError: logger.warning("GroupNicknameNotify: HTML 渲染超时,已跳过") return None async def _fetch_avatar_data_url(self, avatar_url: str) -> str: if not avatar_url: return "" timeout = max(1, int(self.avatar_timeout)) try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: async with session.get(avatar_url) as resp: if resp.status != 200: return "" content = await resp.read() if not content: return "" encoded = base64.b64encode(content).decode("ascii") return f"data:image/png;base64,{encoded}" except Exception: return "" async def _refresh_cache(self, bot: WechatHookClient): try: room_ids = [] if self.enabled_groups: room_ids = [rid for rid in self.enabled_groups if rid.endswith("@chatroom")] else: chatrooms = await bot.get_chatroom_list(force_refresh=True) for entry in chatrooms: if isinstance(entry, dict): contact = entry.get("contact", {}) username = contact.get("userName", {}) if isinstance(username, dict): room_id = username.get("String", "") else: room_id = str(username) else: room_id = str(entry) if room_id.endswith("@chatroom"): room_ids.append(room_id) for room_id in room_ids: members = await bot.get_chatroom_members(room_id) room_cache = self.cache.setdefault(room_id, {}) for member in members: wxid = member.get("wxid") or member.get("userName") or "" if not wxid: continue name = (member.get("display_name") or member.get("nickname") or "").strip() if not name: name = member.get("nickname") or "" if name: room_cache[wxid] = name if room_ids: self._save_cache() logger.info(f"GroupNicknameNotify: 已预加载 {len(room_ids)} 个群的成员昵称缓存") except Exception as e: logger.warning(f"GroupNicknameNotify: 预加载缓存失败: {e}") @on_chatroom_member_nickname_change(priority=50) async def handle_nickname_change(self, bot: WechatHookClient, message: dict): """处理群成员昵称变更事件""" if not self.config: return True if not self.config.get("behavior", {}).get("enabled", True): return True room_wxid = message.get("RoomWxid", "") if not room_wxid or not self._should_notify(room_wxid): return True member_list = message.get("MemberList", []) if not member_list: return True room_cache = self.cache.setdefault(room_wxid, {}) for member in member_list: wxid = member.get("wxid") or "" if not wxid: continue display_name = self._clean_name(member.get("display_name") or "") nickname = self._clean_name(member.get("nickname") or "") if not display_name and not nickname: logger.info( f"GroupNicknameNotify: 空昵称回调已跳过: room={room_wxid}, wxid={wxid}" ) continue new_name = display_name or nickname if not new_name: continue old_name = self._clean_name(room_cache.get(wxid, "")) db_avatar_url = "" if not old_name: old_name = self._clean_name( await self._get_member_sync_group_nickname(room_wxid, wxid) ) if not member.get("avatar"): db_avatar_url = await self._get_member_sync_avatar(room_wxid, wxid) room_cache[wxid] = new_name if not old_name or old_name == new_name: if not old_name: logger.info( f"GroupNicknameNotify: 缺少旧昵称,已跳过: room={room_wxid}, wxid={wxid}" ) continue member_name = nickname or display_name or wxid avatar_url = member.get("avatar", "") or db_avatar_url or "" avatar_data_url = await self._fetch_avatar_data_url(avatar_url) html_content = self._build_card_html( member_name, old_name, new_name, avatar_data_url or avatar_url, ) image_path = await self._render_card(html_content) if image_path: ok = await bot.send_image(room_wxid, image_path) if not ok: logger.warning("GroupNicknameNotify: 图片发送失败,已回退文本") await bot.send_text( room_wxid, f"群成员 {member_name} 将名称 {old_name} 更改为 {new_name}" ) if self.cleanup_image: try: Path(image_path).unlink() except Exception: pass else: await bot.send_text( room_wxid, f"群成员 {member_name} 将名称 {old_name} 更改为 {new_name}" ) self._save_cache() return True