Files
abot/admin/dashboard/blueprints/robot.py
T

509 lines
21 KiB
Python

from flask import Blueprint, render_template, jsonify, request, current_app, redirect
from .auth import login_required
from loguru import logger
from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus
from datetime import datetime, timedelta
# 创建群管理兼容蓝图(旧入口保留做跳转,实际功能已并入通讯录)
robot_bp = Blueprint('robot', __name__, url_prefix='/robot')
LOG = logger
# 旧群权限页入口兼容跳转
@robot_bp.route('/')
@login_required
def robot_management():
return redirect('/contacts')
# API路由
@robot_bp.route('/api/groups')
@login_required
def api_robot_groups():
try:
server = current_app.dashboard_server
# 获取所有群组列表
groups = GroupBotManager.get_group_list()
# 如果方法返回None或发生异常,使用本地缓存
if groups is None and hasattr(GroupBotManager, "local_cache"):
groups = GroupBotManager.local_cache.get("group_list", set())
# 如果仍然为None,则初始化为空集合
if groups is None:
groups = set()
LOG.info(f"获取到 {len(groups)} 个群组")
group_data = []
for group_id in groups:
try:
# 获取群名称,如果失败则使用默认值
group_name = server.contact_manager.get_nickname(group_id)
if not group_name:
group_name = f"未知群组({group_id})"
# 获取机器人状态,如果失败则使用默认值
try:
robot_status = GroupBotManager.get_group_permission(group_id, Feature.ROBOT)
except:
robot_status = PermissionStatus.DISABLED
group_data.append({
"group_id": group_id,
"group_name": group_name,
"robot_status": robot_status.value if robot_status else "unknown"
})
except Exception as e:
LOG.warning(f"处理群组 {group_id} 信息时出错: {e}")
# 添加基本信息,避免单个群组错误影响整个列表
group_data.append({
"group_id": group_id,
"group_name": "获取失败",
"robot_status": "unknown"
})
return jsonify({"success": True, "data": group_data})
except Exception as e:
LOG.error(f"获取群组列表失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@robot_bp.route('/api/group/<group_id>/permissions')
@login_required
def api_robot_group_permissions(group_id):
try:
permissions = GroupBotManager.list_group_permissions(group_id)
permission_data = []
for feature, status in permissions.items():
permission_data.append({
"feature_id": feature.value,
"feature_name": feature.name,
"feature_description": feature.description,
"status": status.value
})
return jsonify({"success": True, "data": permission_data})
except Exception as e:
LOG.error(f"获取群组权限失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@robot_bp.route('/api/group/<group_id>/permissions', methods=['POST'])
@login_required
def api_update_robot_permissions(group_id):
# 更新群组功能权限
server = current_app.dashboard_server
data = request.json
feature_id = data.get('feature_id')
status = data.get('status')
try:
feature = Feature(int(feature_id))
new_status = PermissionStatus(status)
# 特殊处理ROBOT功能
if feature == Feature.ROBOT:
r = server.db_manager.get_redis_connection()
if new_status == PermissionStatus.ENABLED:
GroupBotManager.local_cache["group_list"].add(group_id)
r.sadd("group:list", group_id)
else:
GroupBotManager.local_cache["group_list"].remove(group_id)
r.srem("group:list", group_id)
GroupBotManager.set_group_permission(group_id, feature, new_status)
return jsonify({"success": True})
except Exception as e:
LOG.error(f"更新群组权限失败: {e}")
return jsonify({"success": False, "error": str(e)}), 400
@robot_bp.route('/api/batch_operation', methods=['POST'])
@login_required
def api_robot_batch_operation():
# 批量操作接口
data = request.json
operation = data.get('operation')
group_ids = data.get('group_ids', [])
results = {}
try:
if operation == 'remove_groups':
for group_id in group_ids:
result = GroupBotManager.remove_group(group_id)
results[group_id] = result
return jsonify({"success": True, "results": results})
else:
return jsonify({"success": False, "error": "不支持的操作类型"}), 400
except Exception as e:
LOG.error(f"批量操作失败: {e}")
return jsonify({"success": False, "error": str(e)}), 400
@robot_bp.route('/api/add_group', methods=['POST'])
@login_required
def api_add_group():
try:
server = current_app.dashboard_server
data = request.json
group_id = data.get('group_id')
if not group_id or not group_id.strip():
return jsonify({"success": False, "error": "群组ID不能为空"}), 400
group_id = group_id.strip()
# 如果group_id 不是@chatroom 结尾,这提示错误
if not group_id.endswith("@chatroom"):
return jsonify({"success": False, "error": "群组ID必须以 @chatroom 结尾"}), 400
# 检查群组是否已存在
if group_id in GroupBotManager.local_cache["group_list"]:
return jsonify({"success": False, "error": "该群组已存在"}), 400
# 添加群组到列表并启用机器人功能
GroupBotManager.local_cache["group_list"].add(group_id)
r = server.db_manager.get_redis_connection()
r.sadd("group:list", group_id)
# 设置ROBOT功能为启用状态
GroupBotManager.set_group_permission(group_id, Feature.ROBOT, PermissionStatus.ENABLED)
# 获取群组名称(如果可能)
group_name = server.contact_manager.get_nickname(group_id)
return jsonify({
"success": True,
"message": f"群组 {group_id} 已成功添加",
"group": {
"group_id": group_id,
"group_name": group_name,
"robot_status": "enabled"
}
})
except Exception as e:
LOG.error(f"添加群组失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@robot_bp.route('/api/group/<group_id>/message_trend')
@login_required
def api_group_message_trend(group_id):
try:
server = current_app.dashboard_server
days = request.args.get('days', 7, type=int)
trend_data = server.message_storage.get_message_trend(group_id, days)
# 格式化数据为前端需要的格式
dates = []
counts = []
for item in trend_data:
# 将日期转换为字符串
if isinstance(item['date'], datetime):
date_str = item['date'].strftime('%Y-%m-%d')
else:
date_str = str(item['date'])
dates.append(date_str)
counts.append(item['message_count'])
return jsonify({
'success': True,
'data': {
'dates': dates,
'counts': counts
}
})
except Exception as e:
LOG.error(f"获取群组消息趋势数据出错: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@robot_bp.route('/api/group/<group_id>/detail')
@login_required
def api_group_detail(group_id):
"""群组详情聚合接口"""
try:
server = current_app.dashboard_server
now = datetime.now()
last_24h = now - timedelta(days=1)
last_7d = now - timedelta(days=7)
last_30d = now - timedelta(days=30)
group_name = server.contact_manager.get_nickname(group_id) or group_id
permission_map = GroupBotManager.list_group_permissions(group_id)
enabled_features = [
feature.description
for feature, status in permission_map.items()
if status == PermissionStatus.ENABLED
]
member_summary = server.contact_db.get_group_member_summary(group_id, inactive_days=30)
inactive_members = server.contact_db.get_inactive_members_rank(group_id, days=30, limit=8)
message_stats_24h = server.message_storage.get_message_stats_by_date_range(group_id, last_24h, now)
message_stats_7d = server.message_storage.get_message_stats_by_date_range(group_id, last_7d, now)
message_stats_30d = server.message_storage.get_message_stats_by_date_range(group_id, last_30d, now)
speaker_ranking = server.message_storage.get_group_member_message_ranking(group_id, last_30d, now, limit=10)
plugin_summary = server.stats_db.get_group_plugin_summary(group_id, days=30)
plugin_stats = server.stats_db.get_group_plugin_stats(group_id, days=30, limit=8)
last_message = server.message_storage.get_group_last_message(group_id)
trend_data = server.message_storage.get_message_trend(group_id, 14)
hourly_distribution = server.message_storage.get_group_hourly_distribution(group_id, 30)
for item in inactive_members:
wxid = item.get("wxid")
item["display_name"] = server.contact_manager.get_group_name(group_id, wxid) or item.get("nick_name") or wxid
for item in speaker_ranking:
sender = item.get("sender")
item["display_name"] = server.contact_manager.get_group_name(group_id, sender) or sender
total_members = member_summary.get("in_group_members") or member_summary.get("total_members") or 0
active_30d = member_summary.get("active_30d_members", 0)
zombie_count = member_summary.get("inactive_members", 0)
msg_30d_total = message_stats_30d.get("total_count", 0)
plugin_total_calls = plugin_summary.get("total_calls", 0)
health_score = 100
if total_members > 0:
health_score -= min(35, round((zombie_count / total_members) * 35))
health_score -= min(25, round((1 - min(active_30d / total_members, 1)) * 25))
if msg_30d_total == 0:
health_score -= 25
elif msg_30d_total < 50:
health_score -= 12
if plugin_total_calls == 0:
health_score -= 10
health_score = max(0, min(100, health_score))
trend_dates = []
trend_counts = []
for item in trend_data:
date_val = item.get("date")
if isinstance(date_val, datetime):
date_str = date_val.strftime('%Y-%m-%d')
else:
date_str = str(date_val)
trend_dates.append(date_str)
trend_counts.append(int(item.get("message_count") or 0))
hourly_distribution_sorted = sorted(
hourly_distribution,
key=lambda x: x.get("message_count", 0),
reverse=True
)
peak_hours = [
{
"hour": item.get("hour", 0),
"label": f"{int(item.get('hour', 0)):02d}:00-{(int(item.get('hour', 0)) + 1) % 24:02d}:00",
"message_count": item.get("message_count", 0),
}
for item in hourly_distribution_sorted[:3]
if item.get("message_count", 0) > 0
]
plugin_success_rate = (
round(plugin_summary.get("success_calls", 0) / plugin_total_calls * 100, 1)
if plugin_total_calls else 0
)
zombie_ratio = round((zombie_count / total_members) * 100, 1) if total_members else 0
active_ratio = round((active_30d / total_members) * 100, 1) if total_members else 0
operation_suggestions = []
if zombie_ratio >= 50:
operation_suggestions.append({
"level": "danger",
"title": "僵尸成员偏多,建议分层清理或召回",
"desc": f"当前僵尸成员占比 {zombie_ratio}% ,建议优先筛查 30 天未发言成员,结合群定位决定清理、激活或转移。"
})
elif zombie_ratio >= 30:
operation_suggestions.append({
"level": "warning",
"title": "沉默成员较多,建议做激活动作",
"desc": f"当前僵尸成员占比 {zombie_ratio}% ,可以通过签到、抽奖、任务或话题互动做一次激活。"
})
if active_ratio < 20:
operation_suggestions.append({
"level": "danger",
"title": "活跃覆盖率偏低,群内容可能只被少数人消费",
"desc": f"近30天活跃覆盖率仅 {active_ratio}% ,建议增加低门槛互动,如投票、签到、接龙、关键词触发。"
})
elif active_ratio < 40:
operation_suggestions.append({
"level": "warning",
"title": "活跃度中等,可继续扩大参与面",
"desc": f"近30天活跃覆盖率为 {active_ratio}% ,建议在高峰时段安排固定互动,提高更多成员参与率。"
})
if plugin_total_calls == 0:
operation_suggestions.append({
"level": "info",
"title": "插件渗透率为 0,建议补一次功能引导",
"desc": "这个群近30天没有插件调用,建议置顶菜单、欢迎语或群公告里给出可用指令示例。"
})
elif plugin_summary.get("plugin_count", 0) <= 2:
operation_suggestions.append({
"level": "info",
"title": "插件使用面偏窄,可以扩展场景",
"desc": f"近30天仅使用了 {plugin_summary.get('plugin_count', 0)} 个插件,建议根据群定位补充娱乐、工具或运营型插件。"
})
if plugin_total_calls > 0 and plugin_success_rate < 85:
operation_suggestions.append({
"level": "warning",
"title": "插件成功率偏低,建议检查指令体验",
"desc": f"近30天插件成功率 {plugin_success_rate}% ,可排查失败插件、异常命令和提示文案是否清晰。"
})
if msg_30d_total < 30:
operation_suggestions.append({
"level": "info",
"title": "群消息基数偏低,建议降低互动门槛",
"desc": f"近30天消息量仅 {msg_30d_total} 条,更适合短反馈、轻任务、固定时间提醒等低成本互动。"
})
if not operation_suggestions:
operation_suggestions.append({
"level": "success",
"title": "群整体状态健康,可持续做精细化运营",
"desc": "当前活跃、消息量、插件调用都比较均衡,建议继续围绕高峰时段和高频插件做强化。"
})
return jsonify({
"success": True,
"data": {
"group_id": group_id,
"group_name": group_name,
"health_score": health_score,
"overview": {
"member_count": total_members,
"active_member_count_7d": member_summary.get("active_7d_members", 0),
"active_member_count_30d": active_30d,
"zombie_member_count": zombie_count,
"never_spoken_count": member_summary.get("never_spoken_members", 0),
"message_count_24h": message_stats_24h.get("total_count", 0),
"message_count_7d": message_stats_7d.get("total_count", 0),
"message_count_30d": msg_30d_total,
"plugin_call_count_30d": plugin_total_calls,
"plugin_count_30d": plugin_summary.get("plugin_count", 0),
},
"diagnosis": [
{
"title": "活跃覆盖率",
"value": f"{round((active_30d / total_members) * 100, 1) if total_members else 0}%",
"desc": "近30天发言成员占全群人数比例"
},
{
"title": "僵尸用户占比",
"value": f"{round((zombie_count / total_members) * 100, 1) if total_members else 0}%",
"desc": "30天未发言或从未发言成员占比"
},
{
"title": "人均消息量",
"value": round(msg_30d_total / active_30d, 1) if active_30d else 0,
"desc": "近30天活跃成员的人均发言量"
},
{
"title": "插件渗透度",
"value": f"{plugin_summary.get('plugin_count', 0)} 个插件",
"desc": "近30天在本群真实发生调用的插件数"
}
],
"permissions": {
"enabled_count": len(enabled_features),
"enabled_features": enabled_features,
},
"member_summary": member_summary,
"message_summary": {
"last_message": last_message,
"last_24h": message_stats_24h,
"last_7d": message_stats_7d,
"last_30d": message_stats_30d,
"type_mix_30d": [
{"label": "文本", "count": message_stats_30d.get("text_count", 0)},
{"label": "图片", "count": message_stats_30d.get("image_count", 0)},
{"label": "视频", "count": message_stats_30d.get("video_count", 0)},
{"label": "链接", "count": message_stats_30d.get("link_count", 0)},
{"label": "表情", "count": message_stats_30d.get("emoji_count", 0)},
],
"trend_14d": {
"dates": trend_dates,
"counts": trend_counts
},
"hourly_distribution_30d": hourly_distribution,
"peak_hours_30d": peak_hours,
},
"inactive_members": inactive_members,
"speaker_ranking": speaker_ranking,
"plugin_summary": plugin_summary,
"plugin_stats": plugin_stats,
"operation_suggestions": operation_suggestions,
}
})
except Exception as e:
LOG.error(f"获取群组详情失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# 添加缺失的群组状态更新接口
@robot_bp.route('/api/group/<group_id>/status', methods=['POST'])
@login_required
def api_update_group_status(group_id):
try:
server = current_app.dashboard_server
data = request.json
status = data.get('status')
if status == 'disabled':
# 禁用该群组的所有功能
LOG.info(f"正在禁用群组 {group_id} 的所有功能")
# 获取所有功能并禁用
for feature in Feature:
GroupBotManager.set_group_permission(group_id, feature, PermissionStatus.DISABLED)
# 特殊处理ROBOT功能,从群组列表中移除
if group_id in GroupBotManager.local_cache["group_list"]:
GroupBotManager.local_cache["group_list"].remove(group_id)
# 从Redis中移除
r = server.db_manager.get_redis_connection()
r.srem("group:list", group_id)
return jsonify({
"success": True,
"message": f"群组 {group_id} 的所有功能已禁用"
})
elif status == 'enabled':
# 启用该群组的基本功能
LOG.info(f"正在启用群组 {group_id} 的基本功能")
# 添加到群组列表
if group_id not in GroupBotManager.local_cache["group_list"]:
GroupBotManager.local_cache["group_list"].add(group_id)
# 添加到Redis
r = server.db_manager.get_redis_connection()
r.sadd("group:list", group_id)
# 启用ROBOT基本功能
GroupBotManager.set_group_permission(group_id, Feature.ROBOT, PermissionStatus.ENABLED)
return jsonify({
"success": True,
"message": f"群组 {group_id} 的基本功能已启用"
})
else:
return jsonify({
"success": False,
"error": "不支持的状态值,只接受 'enabled' 或 'disabled'"
}), 400
except Exception as e:
LOG.error(f"更新群组状态失败: {e}")
return jsonify({"success": False, "error": str(e)}), 500