Files
WeChatHookBot/plugins/GroupNicknameNotify/main.py

474 lines
17 KiB
Python

"""
群昵称变动通知插件
检测群成员群昵称变更并发送卡片通知。
"""
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'<img class="avatar" src="{safe_url}" alt="avatar">'
fallback = html.escape((fallback_text or "?")[:1])
return f'<div class="avatar placeholder">{fallback}</div>'
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"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ background: #f3f6fb; font-family: "Microsoft YaHei", sans-serif; }}
#card {{
width: 600px;
padding: 28px;
background: linear-gradient(135deg, #f7f9ff 0%, #eef4ff 100%);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(18, 38, 63, 0.12);
color: #1f2a36;
}}
.title {{
font-size: 22px;
font-weight: 700;
line-height: 1.5;
}}
.title .name {{ color: #2563eb; }}
.title .old {{ color: #6b7280; text-decoration: line-through; }}
.title .new {{ color: #16a34a; }}
.row {{
margin-top: 26px;
display: flex;
align-items: center;
justify-content: space-between;
}}
.profile {{
width: 220px;
text-align: center;
}}
.avatar {{
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.15);
background: #ffffff;
}}
.avatar.placeholder {{
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
color: #475569;
background: #e2e8f0;
}}
.label {{
margin-top: 10px;
font-size: 16px;
color: #0f172a;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.arrow {{
width: 60px;
text-align: center;
font-size: 36px;
color: #94a3b8;
}}
</style>
</head>
<body>
<div id="card">
<div class="title">
群成员 <span class="name">{member_name}</span> 将名称
<span class="old">{old_name}</span> 更改为
<span class="new">{new_name}</span>
</div>
<div class="row">
<div class="profile">
{avatar_html}
<div class="label">{old_name}</div>
</div>
<div class="arrow">&rarr;</div>
<div class="profile">
{avatar_html}
<div class="label">{new_name}</div>
</div>
</div>
</div>
</body>
</html>"""
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