chore: sync current WechatHookBot workspace

This commit is contained in:
2026-03-09 15:48:45 +08:00
parent 4016c1e6eb
commit 9119e2307d
195 changed files with 24438 additions and 17498 deletions

View File

@@ -0,0 +1,541 @@
"""
发言榜插件
每天早上自动统计昨日群聊发言排行榜,并发放积分奖励。
"""
import asyncio
import tomllib
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Tuple
import pymysql
import aiosqlite
from loguru import logger
from utils.plugin_base import PluginBase
from utils.decorators import schedule, on_text_message
# HTML 渲染器(延迟导入)
HTML_RENDERER_AVAILABLE = False
try:
from plugins.SignInPlugin.html_renderer import HtmlRenderer
HTML_RENDERER_AVAILABLE = True
except Exception:
logger.warning("SpeechLeaderboard: HTML 渲染器导入失败,将无法生成图片")
class SpeechLeaderboard(PluginBase):
"""发言榜插件"""
description = "发言榜 - 每日统计群聊发言排行并发放积分"
author = "Assistant"
version = "1.0.0"
dependencies = ["MessageLogger", "SignInPlugin"]
load_priority = 55
def __init__(self):
super().__init__()
self.config = None
self.db_config = None
self.member_sync_db_path = None
self.html_renderer = None
self.templates_dir = Path(__file__).parent / "templates"
self.temp_dir = Path(__file__).parent / "temp"
self.images_dir = Path(__file__).parent / "images"
async def async_init(self):
config_path = Path(__file__).parent / "config.toml"
with open(config_path, "rb") as f:
self.config = tomllib.load(f)
self.templates_dir.mkdir(exist_ok=True)
self.temp_dir.mkdir(exist_ok=True)
self.images_dir.mkdir(exist_ok=True)
render_cfg = self.config.get("render", {})
use_html = bool(render_cfg.get("use_html", True))
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("SpeechLeaderboard: HTML 渲染已启用")
else:
self.html_renderer = None
logger.warning("SpeechLeaderboard: HTML 渲染不可用,将使用文本")
self._resolve_member_sync_db_path()
self._load_message_logger_config()
logger.success("SpeechLeaderboard 插件初始化完成")
def _load_message_logger_config(self):
try:
config_path = Path(__file__).parent.parent / "MessageLogger" / "config.toml"
if not config_path.exists():
return
with open(config_path, "rb") as f:
cfg = tomllib.load(f)
self.db_config = cfg.get("database") or {}
except Exception as e:
logger.warning(f"SpeechLeaderboard: 读取 MessageLogger 配置失败: {e}")
def _get_admins(self) -> List[str]:
admins = self.config.get("behavior", {}).get("admins", []) if self.config else []
admins = [a for a in admins if isinstance(a, str) and a]
if admins:
return admins
try:
main_config_path = Path(__file__).parent.parent.parent / "main_config.toml"
with open(main_config_path, "rb") as f:
cfg = tomllib.load(f)
bot_cfg = cfg.get("Bot", {})
admins = bot_cfg.get("admins", []) or []
return [a for a in admins if isinstance(a, str) and a]
except Exception:
return []
def _resolve_member_sync_db_path(self):
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"SpeechLeaderboard: 解析 MemberSync 配置失败: {e}")
def _get_db_connection(self):
msg_logger = self.get_plugin("MessageLogger")
if msg_logger and hasattr(msg_logger, "get_db_connection"):
try:
return msg_logger.get_db_connection()
except Exception:
pass
if not self.db_config:
return None
return pymysql.connect(
host=self.db_config.get("host"),
port=self.db_config.get("port"),
user=self.db_config.get("user"),
password=self.db_config.get("password"),
database=self.db_config.get("database"),
charset=self.db_config.get("charset", "utf8mb4"),
autocommit=True,
)
def _get_bot_wxid(self) -> str:
try:
main_config_path = Path(__file__).parent.parent.parent / "main_config.toml"
with open(main_config_path, "rb") as f:
cfg = tomllib.load(f)
bot_cfg = cfg.get("Bot", {})
return str(bot_cfg.get("wxid", "")).strip()
except Exception:
return ""
def _extract_chatroom_wxid(self, chatroom_entry) -> str:
if isinstance(chatroom_entry, str):
return chatroom_entry
if not isinstance(chatroom_entry, dict):
return ""
contact = chatroom_entry.get("contact", chatroom_entry)
username = contact.get("userName", "")
if isinstance(username, dict):
return username.get("String", "")
return str(username) if username else ""
async def _get_groups_from_member_sync(self) -> List[str]:
if not self.member_sync_db_path or not self.member_sync_db_path.exists():
return []
try:
async with aiosqlite.connect(self.member_sync_db_path) as db:
cursor = await db.execute(
"SELECT DISTINCT chatroom_wxid FROM group_members WHERE chatroom_wxid != ''"
)
rows = await cursor.fetchall()
return [row[0] for row in rows if row and row[0]]
except Exception as e:
logger.debug(f"SpeechLeaderboard: 查询 MemberSync 群列表失败: {e}")
return []
async def _get_groups_from_messages(self) -> List[str]:
conn = self._get_db_connection()
if not conn:
return []
try:
with conn.cursor() as cursor:
cursor.execute(
"SELECT DISTINCT group_id FROM messages WHERE is_group = 1 AND group_id IS NOT NULL AND group_id != ''"
)
rows = cursor.fetchall()
return [row[0] for row in rows if row and row[0]]
except Exception as e:
logger.debug(f"SpeechLeaderboard: 查询消息表群列表失败: {e}")
return []
finally:
conn.close()
async def _get_target_groups(self, bot) -> List[str]:
enabled_groups = self.config.get("behavior", {}).get("enabled_groups", [])
disabled_groups = set(self.config.get("behavior", {}).get("disabled_groups", []))
groups = [g for g in enabled_groups if isinstance(g, str) and g]
if not groups:
groups = await self._get_groups_from_member_sync()
if not groups:
groups = await self._get_groups_from_messages()
if not groups and bot:
try:
chatrooms = await bot.get_chatroom_list(force_refresh=True)
for item in chatrooms:
wxid = self._extract_chatroom_wxid(item)
if wxid and wxid.endswith("@chatroom"):
groups.append(wxid)
except Exception as e:
logger.debug(f"SpeechLeaderboard: 获取群聊列表失败: {e}")
groups = [g for g in groups if g and g not in disabled_groups]
groups = list(dict.fromkeys(groups))
return groups
def _get_yesterday_range(self) -> Tuple[datetime, datetime, str]:
today = datetime.now().date()
yesterday = today - timedelta(days=1)
start_dt = datetime.combine(yesterday, datetime.min.time())
end_dt = datetime.combine(today, datetime.min.time())
date_label = yesterday.strftime("%Y年%m月%d")
return start_dt, end_dt, date_label
async def _fetch_group_stats(self, group_id: str, start_dt: datetime, end_dt: datetime) -> List[Dict]:
conn = self._get_db_connection()
if not conn:
return []
exclude_bot = bool(self.config.get("behavior", {}).get("exclude_bot", True))
bot_wxid = self._get_bot_wxid() if exclude_bot else ""
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
sql = (
"SELECT sender_wxid, MAX(nickname) AS nickname, MAX(avatar_url) AS avatar_url, "
"COUNT(*) AS msg_count "
"FROM messages "
"WHERE is_group = 1 AND group_id = %s AND create_time >= %s AND create_time < %s "
"AND sender_wxid IS NOT NULL AND sender_wxid != ''"
)
params = [group_id, start_dt, end_dt]
if bot_wxid:
sql += " AND sender_wxid != %s"
params.append(bot_wxid)
sql += " GROUP BY sender_wxid"
cursor.execute(sql, params)
rows = cursor.fetchall()
return rows or []
except Exception as e:
logger.error(f"SpeechLeaderboard: 查询群 {group_id} 发言统计失败: {e}")
return []
finally:
conn.close()
def _calc_points(self, msg_count: int, rank: int) -> int:
per = int(self.config.get("behavior", {}).get("messages_per_point", 5))
bonus = int(self.config.get("behavior", {}).get("dragon_bonus", 5))
base_points = msg_count // max(1, per)
if rank == 1:
return base_points + bonus
return base_points
def _build_html(self, data: Dict) -> str:
leaderboard = data.get("leaderboard", [])
total_speakers = data.get("total_speakers", 0)
total_messages = data.get("total_messages", 0)
date_label = data.get("date", "")
rows_html = ""
medals = ["🐉", "🥈", "🥉"]
for i, user in enumerate(leaderboard):
rank = i + 1
nickname = user.get("nickname") or "未知用户"
msg_count = user.get("msg_count", 0)
points = user.get("points", 0)
avatar_url = user.get("avatar_url", "")
if len(nickname) > 10:
nickname = nickname[:9] + ""
if rank <= 3:
rank_html = f'<span class="medal">{medals[rank-1]}</span>'
row_class = f"top{rank}"
else:
rank_html = f'<span class="rank-num">{rank}</span>'
row_class = ""
if avatar_url:
avatar_html = f'<img class="row-avatar" src="{avatar_url}" alt="">'
else:
avatar_html = '<div class="row-avatar-placeholder">👤</div>'
title_tag = "<span class=\"tag\">龙王</span>" if rank == 1 else ""
rows_html += f'''
<div class="row {row_class}">
<div class="rank">{rank_html}</div>
{avatar_html}
<div class="info">
<div class="name">{nickname} {title_tag}</div>
<div class="stats">发言 {msg_count} | +{points} 分</div>
</div>
<div class="count">{msg_count}</div>
</div>'''
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: "Microsoft YaHei", sans-serif; }}
.container {{ width: 540px; background: #fff; }}
.header {{
background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%);
padding: 22px 20px; text-align: center; color: #4a2c2c;
}}
.header h1 {{ font-size: 26px; margin-bottom: 6px; }}
.header p {{ font-size: 13px; color: #6b4a4a; }}
.summary {{
display: flex; justify-content: space-between;
padding: 12px 18px; font-size: 12px; color: #666;
background: #faf7f7; border-bottom: 1px solid #f0e9e9;
}}
.list {{ padding: 10px 15px; }}
.row {{
display: flex; align-items: center;
padding: 12px 10px; border-bottom: 1px solid #f0f0f0;
}}
.row.top1 {{ background: linear-gradient(90deg, #fff3d6 0%, #fff 100%); }}
.row.top2 {{ background: linear-gradient(90deg, #f5f5f5 0%, #fff 100%); }}
.row.top3 {{ background: linear-gradient(90deg, #fff5f0 0%, #fff 100%); }}
.rank {{ width: 40px; text-align: center; }}
.medal {{ font-size: 22px; }}
.rank-num {{ font-size: 16px; font-weight: bold; color: #999; }}
.row-avatar, .row-avatar-placeholder {{
width: 45px; height: 45px; border-radius: 50%;
margin: 0 12px; object-fit: cover;
}}
.row-avatar-placeholder {{
background: #e0e0e0; display: flex;
align-items: center; justify-content: center; font-size: 20px;
}}
.info {{ flex: 1; }}
.name {{ font-size: 16px; font-weight: 500; color: #333; }}
.tag {{
display: inline-block; margin-left: 6px; padding: 2px 6px;
font-size: 11px; color: #fff; background: #ff6b6b; border-radius: 10px;
}}
.stats {{ font-size: 12px; color: #999; margin-top: 2px; }}
.count {{
font-size: 18px; font-weight: bold; color: #ff6b6b; min-width: 60px; text-align: right;
}}
.footer {{
text-align: center; padding: 12px;
color: #999; font-size: 12px; border-top: 1px solid #f0f0f0;
}}
</style>
</head>
<body>
<div class="container" id="card">
<div class="header">
<h1>📣 昨日发言榜</h1>
<p>{date_label}</p>
</div>
<div class="summary">
<span>发言人数:{total_speakers}</span>
<span>总消息:{total_messages}</span>
<span>龙王奖励:+{self.config.get("behavior", {}).get("dragon_bonus", 5)}分</span>
</div>
<div class="list">{rows_html}</div>
<div class="footer">每日统计 | WechatHookBot</div>
</div>
</body>
</html>'''
async def _render_image(self, data: Dict) -> str:
if not self.html_renderer:
return ""
html = self._build_html(data)
try:
timeout = int(self.config.get("render", {}).get("render_timeout", 20))
return await asyncio.wait_for(
self.html_renderer._render_html(html, "speech_leaderboard"),
timeout=timeout,
)
except asyncio.TimeoutError:
logger.warning("SpeechLeaderboard: HTML 渲染超时")
except Exception as e:
logger.error(f"SpeechLeaderboard: HTML 渲染失败: {e}")
return ""
async def _send_text_fallback(self, bot, group_id: str, data: Dict):
lines = ["📣 昨日发言榜", f"日期:{data.get('date', '')}", "" * 14]
for i, user in enumerate(data.get("leaderboard", []), 1):
nickname = user.get("nickname") or "未知用户"
msg_count = user.get("msg_count", 0)
points = user.get("points", 0)
flag = "(龙王)" if i == 1 else ""
lines.append(f"{i}. {nickname} {flag} - {msg_count}句 +{points}")
lines.append(f"发言人数:{data.get('total_speakers', 0)} | 总消息:{data.get('total_messages', 0)}")
await bot.send_text(group_id, "\n".join(lines))
async def _award_points(self, stats: List[Dict], date_key: str):
signin = self.get_plugin("SignInPlugin")
if not signin:
logger.warning("SpeechLeaderboard: 未找到 SignInPlugin无法发放积分")
return
sorted_stats = sorted(stats, key=lambda x: x.get("msg_count", 0), reverse=True)
for i, row in enumerate(sorted_stats, 1):
wxid = row.get("sender_wxid", "")
if not wxid:
continue
nickname = row.get("nickname") or ""
msg_count = int(row.get("msg_count", 0))
points = self._calc_points(msg_count, i)
if points <= 0:
continue
try:
signin.create_or_update_user(wxid, nickname)
desc = f"昨日发言 {msg_count} 句,奖励 {points} 积分"
if i == 1:
desc = f"龙王奖励:昨日发言 {msg_count} 句,奖励 {points} 积分"
signin.add_points(
wxid,
points,
change_type="speech_rank",
description=desc,
related_id=date_key,
)
except Exception as e:
logger.warning(f"SpeechLeaderboard: 发放积分失败 {wxid}: {e}")
async def _process_group(self, bot, group_id: str, start_dt: datetime, end_dt: datetime, date_label: str, *, award_points: bool = True):
stats = await self._fetch_group_stats(group_id, start_dt, end_dt)
min_speakers = int(self.config.get("behavior", {}).get("min_speakers", 5))
if len(stats) < min_speakers:
logger.info(f"SpeechLeaderboard: 群 {group_id} 昨日发言人数不足 ({len(stats)} < {min_speakers})")
return
stats_sorted = sorted(stats, key=lambda x: x.get("msg_count", 0), reverse=True)
total_messages = sum(int(r.get("msg_count", 0)) for r in stats_sorted)
top_n = int(self.config.get("behavior", {}).get("top_n", 20))
top_list = []
for i, row in enumerate(stats_sorted[:top_n], 1):
msg_count = int(row.get("msg_count", 0))
top_list.append({
"rank": i,
"sender_wxid": row.get("sender_wxid"),
"nickname": row.get("nickname") or "未知用户",
"avatar_url": row.get("avatar_url") or "",
"msg_count": msg_count,
"points": self._calc_points(msg_count, i),
})
date_key = (start_dt.date()).strftime("%Y-%m-%d")
if award_points:
await self._award_points(stats_sorted, date_key)
data = {
"date": date_label,
"total_speakers": len(stats_sorted),
"total_messages": total_messages,
"leaderboard": top_list,
}
image_path = await self._render_image(data)
if image_path:
await bot.send_image(group_id, image_path)
if self.config.get("render", {}).get("cleanup_image", True):
try:
Path(image_path).unlink()
except Exception:
pass
else:
await self._send_text_fallback(bot, group_id, data)
@schedule("cron", hour=9, minute=30)
async def scheduled_report(self, bot=None):
if not self.config or not self.config.get("behavior", {}).get("enabled", True):
return
if not self.config.get("schedule", {}).get("enabled", True):
return
if not bot:
bot = self.get_bot()
if not bot:
logger.error("SpeechLeaderboard: 无法获取 bot 实例")
return
start_dt, end_dt, date_label = self._get_yesterday_range()
groups = await self._get_target_groups(bot)
if not groups:
logger.warning("SpeechLeaderboard: 未找到可统计的群聊")
return
logger.info(f"SpeechLeaderboard: 开始统计昨日发言榜,群数={len(groups)}")
group_interval = float(self.config.get("behavior", {}).get("group_interval", 1.5))
for group_id in groups:
try:
await self._process_group(bot, group_id, start_dt, end_dt, date_label)
await asyncio.sleep(group_interval)
except Exception as e:
logger.error(f"SpeechLeaderboard: 群 {group_id} 处理失败: {e}")
logger.info("SpeechLeaderboard: 昨日发言榜任务完成")
@on_text_message(priority=50)
async def handle_manual_report(self, bot, message: dict):
if not self.config or not self.config.get("behavior", {}).get("enabled", True):
return
content = str(message.get("Content", "")).strip()
keywords = self.config.get("behavior", {}).get("test_keywords", ["/发言榜"])
if content not in keywords:
return True
is_group = bool(message.get("IsGroup", False))
from_wxid = message.get("FromWxid", "")
sender_wxid = message.get("SenderWxid", "")
if not is_group:
await bot.send_text(from_wxid, "❌ 请在群聊中使用该指令")
return False
admins = self._get_admins()
operator_wxid = sender_wxid if is_group else from_wxid
if admins and operator_wxid not in admins:
await bot.send_text(from_wxid, "❌ 权限不足,只有管理员可用")
return False
await bot.send_text(from_wxid, "📣 正在生成昨日发言榜,请稍候...")
start_dt, end_dt, date_label = self._get_yesterday_range()
try:
await self._process_group(bot, from_wxid, start_dt, end_dt, date_label, award_points=False)
except Exception as e:
logger.error(f"SpeechLeaderboard: 手动生成失败: {e}")
await bot.send_text(from_wxid, f"❌ 生成失败: {e}")
return False