474 lines
17 KiB
Python
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">→</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
|