542 lines
22 KiB
Python
542 lines
22 KiB
Python
"""
|
||
发言榜插件
|
||
|
||
每天早上自动统计昨日群聊发言排行榜,并发放积分奖励。
|
||
"""
|
||
|
||
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
|