chore: sync current WechatHookBot workspace
This commit is contained in:
3
plugins/GroupNicknameNotify/__init__.py
Normal file
3
plugins/GroupNicknameNotify/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .main import GroupNicknameNotify
|
||||
|
||||
__all__ = ["GroupNicknameNotify"]
|
||||
29
plugins/GroupNicknameNotify/config.toml
Normal file
29
plugins/GroupNicknameNotify/config.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
# 群昵称变动通知插件配置
|
||||
|
||||
[plugin]
|
||||
enabled = true
|
||||
name = "GroupNicknameNotify"
|
||||
description = "群成员昵称变更时发送卡片通知"
|
||||
|
||||
[behavior]
|
||||
enabled = true
|
||||
# 启用通知的群聊列表(为空则对所有群生效)
|
||||
enabled_groups = []
|
||||
# 禁用通知的群聊列表
|
||||
disabled_groups = []
|
||||
# 启动时预加载群成员昵称缓存(可减少首次变更无法识别的问题)
|
||||
preload_cache = false
|
||||
|
||||
[data]
|
||||
# 是否从 MemberSync 数据库读取群昵称/头像 URL(用于兜底)
|
||||
use_member_sync_db = true
|
||||
|
||||
[render]
|
||||
# 是否使用 HTML 渲染(需要 playwright)
|
||||
use_html = true
|
||||
# 渲染超时时间(秒)
|
||||
render_timeout = 12
|
||||
# 头像下载超时时间(秒)
|
||||
avatar_timeout = 4
|
||||
# 发送后清理临时图片文件
|
||||
cleanup_image = true
|
||||
473
plugins/GroupNicknameNotify/main.py
Normal file
473
plugins/GroupNicknameNotify/main.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
群昵称变动通知插件
|
||||
|
||||
检测群成员群昵称变更并发送卡片通知。
|
||||
"""
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user