diff --git a/plugins/value_rank/config.toml b/plugins/value_rank/config.toml
index a8b4022..3d88fcb 100644
--- a/plugins/value_rank/config.toml
+++ b/plugins/value_rank/config.toml
@@ -1,6 +1,6 @@
[ValueRank]
enable = true
-command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"]
+command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
command-format = """
📊 身价系统命令:
1. 我的身价
@@ -8,10 +8,11 @@ command-format = """
3. 社交热度榜 [名次]
4. 搭子榜 [名次]
5. 社交桥梁榜 [名次]
-6. 我的趋势 [天数]
-7. 身价周报
-8. 身价说明
-9. 重算身价(管理员)
+6. 社交关系图 [人数]
+7. 我的趋势 [天数]
+8. 身价周报
+9. 身价说明
+10. 重算身价(管理员)
"""
# 统计窗口(天)
@@ -30,6 +31,9 @@ base_score_scale = 1000
# 排行默认展示数量
default_rank_limit = 10
max_rank_limit = 50
+default_graph_nodes = 12
+max_graph_nodes = 24
+graph_edge_pool_limit = 300
default_trend_days = 7
max_trend_days = 30
diff --git a/plugins/value_rank/main.py b/plugins/value_rank/main.py
index 40a3679..620b6dd 100644
--- a/plugins/value_rank/main.py
+++ b/plugins/value_rank/main.py
@@ -4,6 +4,7 @@ import math
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
+from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
@@ -13,6 +14,7 @@ from base.plugin_common.plugin_interface import PluginStatus
from db.base import BaseDBOperator
from db.connection import DBConnectionManager
from utils.decorator.plugin_decorators import plugin_stats_decorator
+from utils.markdown_to_image import html_to_image
from utils.robot_cmd.robot_command import PermissionStatus, GroupBotManager
from utils.wechat.contact_manager import ContactManager
@@ -307,6 +309,23 @@ class ValueRankDB(BaseDBOperator):
"""
return self.execute_query(sql, (group_id, user_id, days)) or []
+ def get_social_edges_for_graph(self, group_id: str, social_window_days: int, limit: int) -> List[Dict[str, Any]]:
+ """读取用于绘制社交关系图的边数据。"""
+ sql = """
+ SELECT
+ from_user_id,
+ to_user_id,
+ SUM(mention_count) AS mention_count_window,
+ SUM(interaction_score) AS interaction_score_window
+ FROM t_social_edges_daily
+ WHERE group_id = %s
+ AND stat_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
+ GROUP BY from_user_id, to_user_id
+ ORDER BY interaction_score_window DESC, mention_count_window DESC
+ LIMIT %s
+ """
+ return self.execute_query(sql, (group_id, social_window_days, limit)) or []
+
def get_snapshot_score_map(self, stat_date: str, group_id: str) -> Dict[str, float]:
"""读取某天群内所有用户分数字典:{user_id: score}。"""
sql = """
@@ -474,7 +493,7 @@ class ValueRankPlugin(MessagePluginInterface):
"""
FEATURE_KEY = "VALUE_RANK"
- FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 我的趋势, 身价周报, 身价说明, 重算身价]"
+ FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 社交关系图, 我的趋势, 身价周报, 身价说明, 重算身价]"
@property
def name(self) -> str:
@@ -515,10 +534,10 @@ class ValueRankPlugin(MessagePluginInterface):
# 配置默认值:即使未配置 config.toml,也能以保守参数运行。
self.enable = True
- self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"]
+ self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
self.command_format = (
"我的身价 | 身价排行 [名次] | 社交热度榜 [名次] | 搭子榜 [名次] | "
- "社交桥梁榜 [名次] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
+ "社交桥梁榜 [名次] | 社交关系图 [人数] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
)
self.message_window_days = 7
@@ -535,6 +554,9 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = 10
self.max_rank_limit = 50
+ self.default_graph_nodes = 12
+ self.max_graph_nodes = 24
+ self.graph_edge_pool_limit = 300
self.default_trend_days = 7
self.max_trend_days = 30
self.mention_batch_size = 200
@@ -564,6 +586,9 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = int(cfg.get("default_rank_limit", self.default_rank_limit))
self.max_rank_limit = int(cfg.get("max_rank_limit", self.max_rank_limit))
+ self.default_graph_nodes = int(cfg.get("default_graph_nodes", self.default_graph_nodes))
+ self.max_graph_nodes = int(cfg.get("max_graph_nodes", self.max_graph_nodes))
+ self.graph_edge_pool_limit = int(cfg.get("graph_edge_pool_limit", self.graph_edge_pool_limit))
self.default_trend_days = int(cfg.get("default_trend_days", self.default_trend_days))
self.max_trend_days = int(cfg.get("max_trend_days", self.max_trend_days))
self.mention_batch_size = int(cfg.get("mention_batch_size", self.mention_batch_size))
@@ -647,6 +672,15 @@ class ValueRankPlugin(MessagePluginInterface):
await bot.send_text_message(roomid, text, sender)
return True, "查询成功"
+ if command == "社交关系图":
+ graph_nodes = self._parse_graph_nodes(content)
+ image_path = await self._render_social_graph_image(roomid, graph_nodes)
+ if image_path:
+ await bot.send_image_message(roomid, Path(image_path))
+ return True, "查询成功"
+ await bot.send_text_message(roomid, "📊 近期社交关系数据不足,暂时无法绘制关系图。", sender)
+ return True, "数据不足"
+
if command == "我的趋势":
days = self._parse_trend_days(content)
text = await self._build_my_trend_text(roomid, sender, days)
@@ -1118,6 +1152,225 @@ class ValueRankPlugin(MessagePluginInterface):
lines.append(f"趋势结论:{trend_flag} {delta:+.2f}")
return "\n".join(lines)
+ async def _render_social_graph_image(self, group_id: str, max_nodes: int) -> Optional[str]:
+ """渲染群友社交关系图并返回图片路径。
+
+ 设计说明:
+ 1. 数据层使用近窗口期社交边聚合结果,避免扫全量消息;
+ 2. 先做无向边合并,再筛选“连接度高”的核心节点,控制图复杂度;
+ 3. 通过 markdown2image 的 HTML 截图能力输出图片,统一渲染链路。
+ """
+ if not self.db:
+ return None
+
+ edge_rows = self.db.get_social_edges_for_graph(
+ group_id=group_id,
+ social_window_days=self.social_window_days,
+ limit=max(20, int(self.graph_edge_pool_limit)),
+ )
+ if not edge_rows:
+ return None
+
+ # 先将有向边规整成无向边,防止 A->B 和 B->A 分裂成两条线导致视觉噪声。
+ edge_map: Dict[Tuple[str, str], Dict[str, float]] = {}
+ partner_map: Dict[str, set] = {}
+ node_score_map: Dict[str, float] = {}
+
+ for row in edge_rows:
+ uid_a = str(row.get("from_user_id") or "").strip()
+ uid_b = str(row.get("to_user_id") or "").strip()
+ if not uid_a or not uid_b or uid_a == uid_b:
+ continue
+ a, b = (uid_a, uid_b) if uid_a < uid_b else (uid_b, uid_a)
+ pair_key = (a, b)
+ if pair_key not in edge_map:
+ edge_map[pair_key] = {"mention_count": 0.0, "score": 0.0}
+ edge_map[pair_key]["mention_count"] += float(row.get("mention_count_window") or 0.0)
+ edge_map[pair_key]["score"] += float(row.get("interaction_score_window") or 0.0)
+
+ partner_map.setdefault(a, set()).add(b)
+ partner_map.setdefault(b, set()).add(a)
+ node_score_map[a] = float(node_score_map.get(a, 0.0)) + float(row.get("interaction_score_window") or 0.0)
+ node_score_map[b] = float(node_score_map.get(b, 0.0)) + float(row.get("interaction_score_window") or 0.0)
+
+ if not edge_map:
+ return None
+
+ # 使用“伙伴去重数 + 互动分”混合排序选核心节点,让图既看覆盖面也看互动强度。
+ sorted_nodes = sorted(
+ list(partner_map.keys()),
+ key=lambda uid: (
+ -len(partner_map.get(uid, set())),
+ -float(node_score_map.get(uid, 0.0)),
+ uid,
+ ),
+ )
+ selected_nodes = sorted_nodes[:max_nodes]
+ selected_set = set(selected_nodes)
+ if len(selected_nodes) < 2:
+ return None
+
+ selected_edges: List[Tuple[str, str, float, float]] = []
+ for (a, b), data in edge_map.items():
+ if a in selected_set and b in selected_set:
+ selected_edges.append((a, b, float(data.get("mention_count", 0.0)), float(data.get("score", 0.0))))
+ if not selected_edges:
+ return None
+
+ html_content = self._build_social_graph_html(group_id, selected_nodes, selected_edges, partner_map, node_score_map)
+ if not html_content:
+ return None
+
+ output_dir = Path("temp") / "value_rank"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = output_dir / f"social_graph_{group_id}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png"
+
+ try:
+ await html_to_image(html_content, str(output_path))
+ except Exception as e:
+ self.LOG.error(f"[{self.name}] 社交关系图渲染失败: group={group_id}, error={e}")
+ return None
+
+ return str(output_path.resolve()) if output_path.exists() else None
+
+ def _build_social_graph_html(
+ self,
+ group_id: str,
+ selected_nodes: List[str],
+ selected_edges: List[Tuple[str, str, float, float]],
+ partner_map: Dict[str, set],
+ node_score_map: Dict[str, float],
+ ) -> str:
+ """构建社交关系图 HTML(含 SVG 节点和边)。"""
+ if not selected_nodes:
+ return ""
+
+ import html
+
+ width = 1300
+ height = 900
+ cx, cy = width // 2, height // 2 + 10
+ radius = min(width, height) * 0.33
+ node_count = len(selected_nodes)
+
+ # 计算节点坐标:使用圆形布局,稳定且不依赖额外图算法库。
+ pos_map: Dict[str, Tuple[float, float]] = {}
+ for idx, uid in enumerate(selected_nodes):
+ angle = (2.0 * math.pi * idx) / max(node_count, 1)
+ x = cx + radius * math.cos(angle)
+ y = cy + radius * math.sin(angle)
+ pos_map[uid] = (x, y)
+
+ max_edge_score = max([edge[3] for edge in selected_edges] + [1.0])
+ max_partner_count = max([len(partner_map.get(uid, set())) for uid in selected_nodes] + [1])
+
+ cm = ContactManager.get_instance()
+ edge_svg_parts: List[str] = []
+ for a, b, mention_count, score in selected_edges:
+ ax, ay = pos_map[a]
+ bx, by = pos_map[b]
+ normalized = max(0.12, min(score / max_edge_score, 1.0))
+ stroke_width = 1.0 + 7.0 * normalized
+ opacity = 0.20 + 0.55 * normalized
+ edge_svg_parts.append(
+ f'