From 3fc46248d97d0f05006ff7131b4ddc0316d5c5c8 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 12 Feb 2026 22:21:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BE=A4?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三个群管理分析工具: - analyze_member_messages: 成员消息分析(消息统计、类型分布、活跃度) - analyze_join_statistics: 加群统计分析(人数统计、趋势分析) - analyze_new_member_activity: 新成员活跃度分析(活跃率、排行榜) 新增共享工具函数: - time_utils.py: 时间解析工具 - message_utils.py: 消息处理工具 - member_utils.py: 成员处理工具 Co-Authored-By: Claude Sonnet 4.5 --- .../analyze_join_statistics/config.json | 40 +++ .../analyze_join_statistics/handler.py | 162 ++++++++++ .../analyze_member_messages/config.json | 46 +++ .../analyze_member_messages/handler.py | 154 +++++++++ .../analyze_new_member_activity/config.json | 35 ++ .../analyze_new_member_activity/handler.py | 137 ++++++++ src/Undefined/utils/member_utils.py | 169 ++++++++++ src/Undefined/utils/message_utils.py | 305 ++++++++++++++++++ src/Undefined/utils/time_utils.py | 47 +++ 9 files changed, 1095 insertions(+) create mode 100644 src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json create mode 100644 src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py create mode 100644 src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json create mode 100644 src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py create mode 100644 src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json create mode 100644 src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py create mode 100644 src/Undefined/utils/member_utils.py create mode 100644 src/Undefined/utils/message_utils.py create mode 100644 src/Undefined/utils/time_utils.py diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json b/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json new file mode 100644 index 00000000..410c0a6e --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/config.json @@ -0,0 +1,40 @@ +{ + "type": "function", + "function": { + "name": "analyze_join_statistics", + "description": "分析群的加群情况,提供加群人数统计和趋势分析。包含:按时间筛选成员、人数统计、加群趋势分析、可选的成员列表。", + "parameters": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "群号。如果已在群聊中,通常会自动获取。" + }, + "start_time": { + "type": "string", + "description": "开始时间,格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-01 00:00:00" + }, + "end_time": { + "type": "string", + "description": "结束时间,格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-10 23:59:59" + }, + "include_trend": { + "type": "boolean", + "description": "是否包含趋势分析,默认 true", + "default": true + }, + "include_member_list": { + "type": "boolean", + "description": "是否返回成员列表,默认 false", + "default": false + }, + "member_limit": { + "type": "integer", + "description": "返回成员列表的数量限制(最大100),默认 20", + "default": 20 + } + }, + "required": [] + } + } +} diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py b/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py new file mode 100644 index 00000000..76779afb --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py @@ -0,0 +1,162 @@ +"""加群统计分析工具""" + +import logging +from typing import Any, Dict +from datetime import datetime + +from Undefined.utils.time_utils import parse_time_range, format_datetime +from Undefined.utils.member_utils import filter_by_join_time, analyze_join_trend + +logger = logging.getLogger(__name__) + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """分析群的加群情况""" + request_id = str(context.get("request_id", "-")) + group_id = args.get("group_id") or context.get("group_id") + start_time = args.get("start_time") + end_time = args.get("end_time") + include_trend = args.get("include_trend", True) + include_member_list = args.get("include_member_list", False) + member_limit = min(args.get("member_limit", 20), 100) + + # 1. 参数验证 + if not group_id: + return "请提供群号(group_id 参数),或者在群聊中调用" + + try: + group_id = int(group_id) + except (ValueError, TypeError): + return "参数类型错误:group_id 必须是整数" + + # 2. 解析时间范围 + start_dt, end_dt = parse_time_range(start_time, end_time) + + # 验证时间格式 + if start_time and start_dt is None: + return "开始时间格式错误,请使用格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-01 00:00:00" + if end_time and end_dt is None: + return "结束时间格式错误,请使用格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-10 23:59:59" + + onebot_client = context.get("onebot_client") + if not onebot_client: + return "加群统计功能不可用(OneBot 客户端未设置)" + + try: + # 3. 获取群成员列表 + logger.info(f"开始获取群 {group_id} 的成员列表") + member_list = await onebot_client.get_group_member_list(group_id) + logger.info(f"获取到 {len(member_list)} 个成员") + + if not member_list: + return f"群 {group_id} 没有成员数据" + + # 4. 按加群时间筛选 + filtered_members = filter_by_join_time(member_list, start_dt, end_dt) + + if not filtered_members: + time_range_str = "" + if start_dt or end_dt: + time_range_str = f"在时间范围 {format_datetime(start_dt)} ~ {format_datetime(end_dt)} 内" + return f"{time_range_str}没有成员加群" + + # 5. 格式化返回 + result_parts = ["【加群统计分析】"] + result_parts.append(f"群号: {group_id}") + + if start_dt or end_dt: + result_parts.append( + f"时间范围: {format_datetime(start_dt)} ~ {format_datetime(end_dt)}" + ) + + result_parts.append("") + result_parts.append("━━━━━━━━━━━━") + result_parts.append("📊 加群统计") + result_parts.append(f"总人数: {len(filtered_members)} 人") + + # 找出首次和最后加群时间 + join_times = [] + for member in filtered_members: + join_time = member.get("join_time") + if join_time: + try: + if isinstance(join_time, (int, float)): + join_dt = datetime.fromtimestamp(join_time) + join_times.append(join_dt) + except (ValueError, OSError, OverflowError): + pass + + if join_times: + first_join = min(join_times) + last_join = max(join_times) + result_parts.append(f"首次加群: {first_join.strftime('%Y-%m-%d %H:%M:%S')}") + result_parts.append(f"最后加群: {last_join.strftime('%Y-%m-%d %H:%M:%S')}") + + # 6. 可选:趋势分析 + if include_trend: + trend_stats = analyze_join_trend(filtered_members) + if trend_stats: + result_parts.append("") + result_parts.append("━━━━━━━━━━━━") + result_parts.append("📈 加群趋势") + result_parts.append( + f" • 平均每天: {trend_stats.get('avg_per_day', 0)} 人" + ) + + peak_date = trend_stats.get("peak_date") + peak_count = trend_stats.get("peak_count", 0) + if peak_date: + result_parts.append( + f" • 加群高峰日: {peak_date} ({peak_count} 人)" + ) + + daily_stats = trend_stats.get("daily_stats", {}) + if daily_stats: + result_parts.append("") + result_parts.append("每日加群人数:") + # 按日期排序 + sorted_dates = sorted(daily_stats.items()) + for date_str, count in sorted_dates: + # 使用简单的条形图 + bar = "█" * min(count, 20) + result_parts.append(f" {date_str}: {bar} {count} 人") + + # 7. 可选:成员列表 + if include_member_list: + result_parts.append("") + result_parts.append( + f"新成员列表 (显示前 {min(member_limit, len(filtered_members))} 人)" + ) + + # 按加群时间排序 + sorted_members = sorted( + filtered_members, key=lambda m: m.get("join_time", 0) + ) + + for i, member in enumerate(sorted_members[:member_limit], 1): + nickname = member.get("card") or member.get("nickname", "未知") + user_id = member.get("user_id", 0) + join_time = member.get("join_time") + join_time_str = "" + if join_time: + try: + if isinstance(join_time, (int, float)): + join_dt = datetime.fromtimestamp(join_time) + join_time_str = join_dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError, OverflowError): + pass + + result_parts.append( + f"{i}. {nickname} ({user_id}) - 加群: {join_time_str}" + ) + + return "\n".join(result_parts) + + except Exception as e: + logger.exception( + "分析加群统计失败: group=%s request_id=%s err=%s", + group_id, + request_id, + e, + ) + return f"分析失败:{str(e)}" diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json b/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json new file mode 100644 index 00000000..188e2356 --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/config.json @@ -0,0 +1,46 @@ +{ + "type": "function", + "function": { + "name": "analyze_member_messages", + "description": "分析指定群成员的消息情况,提供全面的消息统计和活跃度分析。包含:消息数量统计、消息类型分布、活跃时段分析、可选的消息内容获取。", + "parameters": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "群号。如果已在群聊中,通常会自动获取。" + }, + "user_id": { + "type": "integer", + "description": "要分析的成员QQ号" + }, + "start_time": { + "type": "string", + "description": "开始时间,格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-01 00:00:00" + }, + "end_time": { + "type": "string", + "description": "结束时间,格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-10 23:59:59" + }, + "include_messages": { + "type": "boolean", + "description": "是否返回具体消息内容,默认 false", + "default": false + }, + "message_limit": { + "type": "integer", + "description": "返回消息内容的数量限制(最大100),默认 20", + "default": 20 + }, + "max_history_count": { + "type": "integer", + "description": "最多获取的历史消息数量(最大5000),默认 2000", + "default": 2000 + } + }, + "required": [ + "user_id" + ] + } + } +} diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py b/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py new file mode 100644 index 00000000..65eccc62 --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py @@ -0,0 +1,154 @@ +"""成员消息分析工具""" + +import logging +from typing import Any, Dict + +from Undefined.utils.time_utils import parse_time_range, format_datetime +from Undefined.utils.message_utils import ( + fetch_group_messages, + filter_user_messages, + count_message_types, + analyze_activity_pattern, + format_messages, +) + +logger = logging.getLogger(__name__) + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """分析指定群成员的消息情况""" + request_id = str(context.get("request_id", "-")) + group_id = args.get("group_id") or context.get("group_id") + user_id = args.get("user_id") + start_time = args.get("start_time") + end_time = args.get("end_time") + include_messages = args.get("include_messages", False) + message_limit = min(args.get("message_limit", 20), 100) + max_history_count = min(args.get("max_history_count", 2000), 5000) + + # 1. 参数验证 + if not group_id: + return "请提供群号(group_id 参数),或者在群聊中调用" + if not user_id: + return "请提供要分析的成员QQ号(user_id 参数)" + + try: + group_id = int(group_id) + user_id = int(user_id) + except (ValueError, TypeError): + return "参数类型错误:group_id 和 user_id 必须是整数" + + # 2. 解析时间范围 + start_dt, end_dt = parse_time_range(start_time, end_time) + + # 验证时间格式 + if start_time and start_dt is None: + return "开始时间格式错误,请使用格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-01 00:00:00" + if end_time and end_dt is None: + return "结束时间格式错误,请使用格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-10 23:59:59" + + onebot_client = context.get("onebot_client") + if not onebot_client: + return "消息分析功能不可用(OneBot 客户端未设置)" + + try: + # 3. 获取群消息历史 + logger.info(f"开始获取群 {group_id} 的消息历史,最多 {max_history_count} 条") + all_messages = await fetch_group_messages( + onebot_client, group_id, max_history_count, start_dt + ) + logger.info(f"获取到 {len(all_messages)} 条历史消息") + + # 4. 筛选目标用户的消息 + user_messages = filter_user_messages(all_messages, user_id, start_dt, end_dt) + + if not user_messages: + time_range_str = "" + if start_dt or end_dt: + time_range_str = f"在时间范围 {format_datetime(start_dt)} ~ {format_datetime(end_dt)} 内" + return f"成员 {user_id} {time_range_str}无消息记录" + + # 5. 统计分析 + total_count = len(user_messages) + type_stats = count_message_types(user_messages) + activity_stats = analyze_activity_pattern(user_messages) + + # 6. 获取成员信息 + member_info = await onebot_client.get_group_member_info(group_id, user_id) + member_name = "未知" + if member_info: + member_name = member_info.get("card") or member_info.get("nickname", "未知") + + # 7. 格式化返回 + result_parts = ["【成员消息分析】"] + result_parts.append(f"群号: {group_id}") + result_parts.append(f"成员: {member_name} ({user_id})") + + if start_dt or end_dt: + result_parts.append( + f"时间范围: {format_datetime(start_dt)} ~ {format_datetime(end_dt)}" + ) + + result_parts.append("") + result_parts.append("━━━━━━━━━━━━") + result_parts.append("📊 消息统计") + result_parts.append(f"总消息数: {total_count} 条") + + if type_stats: + result_parts.append("") + result_parts.append("消息类型分布:") + for msg_type, count in sorted( + type_stats.items(), key=lambda x: x[1], reverse=True + ): + percentage = count / total_count * 100 + result_parts.append(f" • {msg_type}: {count} 条 ({percentage:.1f}%)") + + if activity_stats: + result_parts.append("") + result_parts.append("━━━━━━━━━━━━") + result_parts.append("📈 活跃度分析") + result_parts.append( + f" • 平均每天: {activity_stats.get('avg_per_day', 0)} 条消息" + ) + result_parts.append( + f" • 最活跃时段: {activity_stats.get('most_active_hour', '未知')}" + ) + result_parts.append( + f" • 最活跃日期: {activity_stats.get('most_active_weekday', '未知')}" + ) + + first_time = activity_stats.get("first_time") + last_time = activity_stats.get("last_time") + if first_time: + result_parts.append( + f" • 首次发言: {first_time.strftime('%Y-%m-%d %H:%M:%S')}" + ) + if last_time: + result_parts.append( + f" • 最后发言: {last_time.strftime('%Y-%m-%d %H:%M:%S')}" + ) + + # 8. 可选:获取消息内容 + if include_messages: + formatted_msgs = format_messages(user_messages[:message_limit]) + result_parts.append("") + result_parts.append(f"最近消息内容 (显示最近 {len(formatted_msgs)} 条)") + for msg in formatted_msgs: + result_parts.append( + f'' + ) + result_parts.append(f"{msg['content']}") + result_parts.append("") + result_parts.append("---") + + return "\n".join(result_parts) + + except Exception as e: + logger.exception( + "分析成员消息失败: group=%s user=%s request_id=%s err=%s", + group_id, + user_id, + request_id, + e, + ) + return f"分析失败:{str(e)}" diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json b/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json new file mode 100644 index 00000000..118b896c --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/config.json @@ -0,0 +1,35 @@ +{ + "type": "function", + "function": { + "name": "analyze_new_member_activity", + "description": "分析新成员的活跃情况,帮助了解新成员的融入程度。包含:活跃度统计、最活跃成员排行、发言分布分析。", + "parameters": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "description": "群号。如果已在群聊中,通常会自动获取。" + }, + "join_start_time": { + "type": "string", + "description": "加群开始时间,格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-01 00:00:00" + }, + "join_end_time": { + "type": "string", + "description": "加群结束时间,格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-10 23:59:59" + }, + "max_history_count": { + "type": "integer", + "description": "最多获取的历史消息数量(最大5000),默认 2000", + "default": 2000 + }, + "top_count": { + "type": "integer", + "description": "显示最活跃成员的数量(最大20),默认 5", + "default": 5 + } + }, + "required": [] + } + } +} diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py b/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py new file mode 100644 index 00000000..df9e26ad --- /dev/null +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py @@ -0,0 +1,137 @@ +"""新成员活跃度分析工具""" + +import logging +from typing import Any, Dict + +from Undefined.utils.time_utils import parse_time_range, format_datetime +from Undefined.utils.member_utils import filter_by_join_time, analyze_member_activity +from Undefined.utils.message_utils import fetch_group_messages, count_messages_by_user + +logger = logging.getLogger(__name__) + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """分析新成员的活跃情况""" + request_id = str(context.get("request_id", "-")) + group_id = args.get("group_id") or context.get("group_id") + join_start_time = args.get("join_start_time") + join_end_time = args.get("join_end_time") + max_history_count = min(args.get("max_history_count", 2000), 5000) + top_count = min(args.get("top_count", 5), 20) + + # 1. 参数验证 + if not group_id: + return "请提供群号(group_id 参数),或者在群聊中调用" + + try: + group_id = int(group_id) + except (ValueError, TypeError): + return "参数类型错误:group_id 必须是整数" + + # 2. 解析时间范围 + start_dt, end_dt = parse_time_range(join_start_time, join_end_time) + + # 验证时间格式 + if join_start_time and start_dt is None: + return "加群开始时间格式错误,请使用格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-01 00:00:00" + if join_end_time and end_dt is None: + return "加群结束时间格式错误,请使用格式:YYYY-MM-DD HH:MM:SS,例如:2024-02-10 23:59:59" + + onebot_client = context.get("onebot_client") + if not onebot_client: + return "新成员活跃度分析功能不可用(OneBot 客户端未设置)" + + try: + # 3. 获取群成员列表 + logger.info(f"开始获取群 {group_id} 的成员列表") + member_list = await onebot_client.get_group_member_list(group_id) + logger.info(f"获取到 {len(member_list)} 个成员") + + if not member_list: + return f"群 {group_id} 没有成员数据" + + # 4. 筛选新成员 + new_members = filter_by_join_time(member_list, start_dt, end_dt) + + if not new_members: + time_range_str = "" + if start_dt or end_dt: + time_range_str = f"在时间范围 {format_datetime(start_dt)} ~ {format_datetime(end_dt)} 内" + return f"{time_range_str}没有新成员加群" + + # 5. 获取群消息历史 + logger.info(f"开始获取群 {group_id} 的消息历史,最多 {max_history_count} 条") + all_messages = await fetch_group_messages( + onebot_client, group_id, max_history_count, start_dt + ) + logger.info(f"获取到 {len(all_messages)} 条历史消息") + + # 6. 统计每个新成员的发言情况 + member_ids: set[int] = set() + for m in new_members: + user_id = m.get("user_id") + if user_id is not None and isinstance(user_id, int): + member_ids.add(user_id) + member_message_counts = count_messages_by_user(all_messages, member_ids) + + # 7. 分析活跃度 + activity_stats = analyze_member_activity( + new_members, member_message_counts, top_count + ) + + # 8. 格式化返回 + result_parts = ["【新成员活跃度分析】"] + result_parts.append(f"群号: {group_id}") + + if start_dt or end_dt: + result_parts.append( + f"加群时间范围: {format_datetime(start_dt)} ~ {format_datetime(end_dt)}" + ) + + result_parts.append("") + result_parts.append("━━━━━━━━━━━━") + result_parts.append("📊 活跃度统计") + result_parts.append(f"新成员总数: {activity_stats.get('total_members', 0)} 人") + result_parts.append( + f"活跃成员: {activity_stats.get('active_members', 0)} 人 " + f"({activity_stats.get('active_rate', 0)}%)" + ) + result_parts.append( + f"未发言成员: {activity_stats.get('inactive_members', 0)} 人 " + f"({100 - activity_stats.get('active_rate', 0):.1f}%)" + ) + + result_parts.append("") + result_parts.append(f"总发言数: {activity_stats.get('total_messages', 0)} 条") + result_parts.append(f"人均发言: {activity_stats.get('avg_messages', 0)} 条") + + # 显示最活跃成员 + top_members = activity_stats.get("top_members", []) + if top_members: + result_parts.append("") + result_parts.append("━━━━━━━━━━━━") + result_parts.append(f"🔥 最活跃新成员 Top {len(top_members)}") + + for i, member in enumerate(top_members, 1): + nickname = member.get("nickname", "未知") + user_id = member.get("user_id", 0) + message_count = member.get("message_count", 0) + join_time = member.get("join_time", "") + + result_parts.append( + f"{i}. {nickname} ({user_id}) - {message_count} 条消息" + ) + if join_time: + result_parts.append(f" 加群时间: {join_time}") + result_parts.append("") + + return "\n".join(result_parts) + + except Exception as e: + logger.exception( + "分析新成员活跃度失败: group=%s request_id=%s err=%s", + group_id, + request_id, + e, + ) + return f"分析失败:{str(e)}" diff --git a/src/Undefined/utils/member_utils.py b/src/Undefined/utils/member_utils.py new file mode 100644 index 00000000..3b48ab46 --- /dev/null +++ b/src/Undefined/utils/member_utils.py @@ -0,0 +1,169 @@ +"""成员处理工具函数""" + +from datetime import datetime +from typing import Any, Dict +from collections import Counter + + +def filter_by_join_time( + members: list[Dict[str, Any]], start_dt: datetime | None, end_dt: datetime | None +) -> list[Dict[str, Any]]: + """按加群时间筛选成员 + + 参数: + members: 成员列表 + start_dt: 开始时间 + end_dt: 结束时间 + + 返回: + 筛选后的成员列表 + """ + filtered: list[Dict[str, Any]] = [] + + for member in members: + join_time = member.get("join_time") + if join_time is None: + continue + + try: + # 转换为 datetime + if isinstance(join_time, (int, float)): + join_dt = datetime.fromtimestamp(join_time) + else: + continue + + # 检查时间范围 + if start_dt and join_dt < start_dt: + continue + if end_dt and join_dt > end_dt: + continue + + filtered.append(member) + except (ValueError, OSError, OverflowError): + continue + + return filtered + + +def analyze_join_trend(members: list[Dict[str, Any]]) -> Dict[str, Any]: + """分析加群趋势 + + 参数: + members: 成员列表 + + 返回: + 趋势分析字典 + """ + if not members: + return {} + + # 按日期统计 + date_counter: Counter[str] = Counter() + first_time: datetime | None = None + last_time: datetime | None = None + + for member in members: + join_time = member.get("join_time") + if join_time is None: + continue + + try: + if isinstance(join_time, (int, float)): + join_dt = datetime.fromtimestamp(join_time) + else: + continue + + date_str = join_dt.strftime("%Y-%m-%d") + date_counter[date_str] += 1 + + if first_time is None or join_dt < first_time: + first_time = join_dt + if last_time is None or join_dt > last_time: + last_time = join_dt + except (ValueError, OSError, OverflowError): + continue + + # 计算平均每天加群人数 + total_days = len(date_counter) + avg_per_day = len(members) / total_days if total_days > 0 else 0 + + # 找出加群高峰日 + peak_date = "" + peak_count = 0 + if date_counter: + peak_date, peak_count = date_counter.most_common(1)[0] + + return { + "avg_per_day": round(avg_per_day, 1), + "peak_date": peak_date, + "peak_count": peak_count, + "first_time": first_time, + "last_time": last_time, + "daily_stats": dict(date_counter), + } + + +def analyze_member_activity( + members: list[Dict[str, Any]], message_counts: Dict[int, int], top_count: int +) -> Dict[str, Any]: + """分析成员活跃度 + + 参数: + members: 成员列表 + message_counts: 成员消息数量字典 + top_count: 显示最活跃成员的数量 + + 返回: + 活跃度分析字典 + """ + total_members = len(members) + active_members = sum(1 for count in message_counts.values() if count > 0) + inactive_members = total_members - active_members + + total_messages = sum(message_counts.values()) + avg_messages = total_messages / total_members if total_members > 0 else 0 + + # 找出最活跃的成员 + sorted_members = sorted(message_counts.items(), key=lambda x: x[1], reverse=True)[ + :top_count + ] + + # 获取成员详细信息 + member_map = {m.get("user_id"): m for m in members} + top_members: list[Dict[str, Any]] = [] + + for user_id, count in sorted_members: + if count == 0: + break + member = member_map.get(user_id, {}) + nickname = member.get("card") or member.get("nickname", "未知") + join_time = member.get("join_time") + join_time_str = "" + if join_time: + try: + if isinstance(join_time, (int, float)): + join_dt = datetime.fromtimestamp(join_time) + join_time_str = join_dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError, OverflowError): + pass + + top_members.append( + { + "user_id": user_id, + "nickname": nickname, + "message_count": count, + "join_time": join_time_str, + } + ) + + return { + "total_members": total_members, + "active_members": active_members, + "inactive_members": inactive_members, + "active_rate": round(active_members / total_members * 100, 1) + if total_members > 0 + else 0, + "total_messages": total_messages, + "avg_messages": round(avg_messages, 2), + "top_members": top_members, + } diff --git a/src/Undefined/utils/message_utils.py b/src/Undefined/utils/message_utils.py new file mode 100644 index 00000000..64336cbb --- /dev/null +++ b/src/Undefined/utils/message_utils.py @@ -0,0 +1,305 @@ +"""消息处理工具函数""" + +import logging +from datetime import datetime +from typing import Any, Dict, TYPE_CHECKING +from collections import Counter + +from Undefined.onebot import parse_message_time + +if TYPE_CHECKING: + from Undefined.onebot import OneBotClient + +logger = logging.getLogger(__name__) + + +async def fetch_group_messages( + onebot_client: "OneBotClient", + group_id: int, + max_count: int, + start_dt: datetime | None = None, +) -> list[Dict[str, Any]]: + """分批获取群消息历史,支持提前终止 + + 参数: + onebot_client: OneBot 客户端实例 + group_id: 群号 + max_count: 最多获取的消息数量 + start_dt: 开始时间,如果提供则在消息早于此时间时停止获取 + + 返回: + 消息列表 + """ + all_messages: list[Dict[str, Any]] = [] + message_seq: int | None = None + batch_size = 500 + + try: + while len(all_messages) < max_count: + # 计算本次需要获取的数量 + remaining = max_count - len(all_messages) + count = min(batch_size, remaining) + + # 获取一批消息 + messages = await onebot_client.get_group_msg_history( + group_id, message_seq, count + ) + + if not messages: + break + + # 检查是否需要提前终止 + if start_dt: + filtered_messages = [] + for msg in messages: + msg_time = parse_message_time(msg) + if msg_time >= start_dt: + filtered_messages.append(msg) + else: + # 消息已早于开始时间,停止获取 + all_messages.extend(filtered_messages) + return all_messages + messages = filtered_messages + + all_messages.extend(messages) + + # 如果返回的消息数少于请求数,说明已经没有更多消息 + if len(messages) < count: + break + + # 更新 message_seq 为最早消息的 seq + if messages: + last_msg = messages[-1] + message_seq = last_msg.get("message_seq") + if message_seq is None: + break + + except Exception as e: + logger.error(f"获取群消息历史失败: {e}") + + return all_messages + + +def filter_user_messages( + messages: list[Dict[str, Any]], + user_id: int, + start_dt: datetime | None, + end_dt: datetime | None, +) -> list[Dict[str, Any]]: + """筛选特定用户在时间范围内的消息 + + 参数: + messages: 消息列表 + user_id: 用户QQ号 + start_dt: 开始时间 + end_dt: 结束时间 + + 返回: + 筛选后的消息列表 + """ + filtered: list[Dict[str, Any]] = [] + + for msg in messages: + # 检查发送者 + sender = msg.get("sender", {}) + msg_user_id = sender.get("user_id", 0) + if msg_user_id != user_id: + continue + + # 检查时间范围 + msg_time = parse_message_time(msg) + if start_dt and msg_time < start_dt: + continue + if end_dt and msg_time > end_dt: + continue + + filtered.append(msg) + + return filtered + + +def count_message_types(messages: list[Dict[str, Any]]) -> Dict[str, int]: + """统计消息类型分布 + + 参数: + messages: 消息列表 + + 返回: + 类型统计字典,格式:{"文本消息": 10, "图片消息": 5, ...} + """ + type_counter: Counter[str] = Counter() + + for msg in messages: + message_content = msg.get("message", []) + if isinstance(message_content, str): + type_counter["文本消息"] += 1 + continue + + if not isinstance(message_content, list): + continue + + # 统计消息段类型 + has_text = False + has_image = False + has_face = False + has_reply = False + has_other = False + + for segment in message_content: + seg_type = segment.get("type", "") + if seg_type == "text": + has_text = True + elif seg_type == "image": + has_image = True + elif seg_type == "face": + has_face = True + elif seg_type == "reply": + has_reply = True + else: + has_other = True + + # 优先级:回复 > 图片 > 表情 > 其他 > 文本 + if has_reply: + type_counter["回复消息"] += 1 + elif has_image: + type_counter["图片消息"] += 1 + elif has_face: + type_counter["表情消息"] += 1 + elif has_other: + type_counter["其他消息"] += 1 + elif has_text: + type_counter["文本消息"] += 1 + else: + type_counter["空消息"] += 1 + + return dict(type_counter) + + +def analyze_activity_pattern(messages: list[Dict[str, Any]]) -> Dict[str, Any]: + """分析消息活跃度模式 + + 参数: + messages: 消息列表 + + 返回: + 活跃度统计字典 + """ + if not messages: + return {} + + # 按小时统计 + hour_counter: Counter[int] = Counter() + # 按星期统计 + weekday_counter: Counter[int] = Counter() + # 按日期统计 + date_counter: Counter[str] = Counter() + + first_time: datetime | None = None + last_time: datetime | None = None + + for msg in messages: + msg_time = parse_message_time(msg) + hour_counter[msg_time.hour] += 1 + weekday_counter[msg_time.weekday()] += 1 + date_counter[msg_time.strftime("%Y-%m-%d")] += 1 + + if first_time is None or msg_time < first_time: + first_time = msg_time + if last_time is None or msg_time > last_time: + last_time = msg_time + + # 找出最活跃时段 + most_active_hour = hour_counter.most_common(1)[0][0] if hour_counter else 0 + most_active_hour_str = f"{most_active_hour:02d}:00-{most_active_hour:02d}:59" + + # 找出最活跃星期 + weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + most_active_weekday = weekday_counter.most_common(1)[0][0] if weekday_counter else 0 + most_active_weekday_str = weekday_names[most_active_weekday] + + # 计算平均每天消息数 + total_days = len(date_counter) + avg_per_day = len(messages) / total_days if total_days > 0 else 0 + + return { + "avg_per_day": round(avg_per_day, 1), + "most_active_hour": most_active_hour_str, + "most_active_weekday": most_active_weekday_str, + "first_time": first_time, + "last_time": last_time, + } + + +def count_messages_by_user( + messages: list[Dict[str, Any]], user_ids: set[int] +) -> Dict[int, int]: + """统计指定用户的消息数量 + + 参数: + messages: 消息列表 + user_ids: 用户QQ号集合 + + 返回: + 用户消息数量字典,格式:{user_id: count} + """ + counter: Dict[int, int] = {uid: 0 for uid in user_ids} + + for msg in messages: + sender = msg.get("sender", {}) + msg_user_id = sender.get("user_id", 0) + if msg_user_id in user_ids: + counter[msg_user_id] += 1 + + return counter + + +def format_messages(messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """格式化消息列表用于显示 + + 参数: + messages: 消息列表 + + 返回: + 格式化后的消息列表 + """ + formatted: list[Dict[str, Any]] = [] + + for msg in messages: + sender = msg.get("sender", {}) + sender_name = sender.get("card") or sender.get("nickname", "未知") + sender_id = sender.get("user_id", 0) + msg_time = parse_message_time(msg) + + # 提取消息文本 + message_content = msg.get("message", []) + text_parts: list[str] = [] + + if isinstance(message_content, str): + text_parts.append(message_content) + elif isinstance(message_content, list): + for segment in message_content: + seg_type = segment.get("type", "") + if seg_type == "text": + data = segment.get("data", {}) + text_parts.append(data.get("text", "")) + elif seg_type == "image": + text_parts.append("[图片]") + elif seg_type == "face": + text_parts.append("[表情]") + elif seg_type == "reply": + text_parts.append("[回复]") + else: + text_parts.append(f"[{seg_type}]") + + content = "".join(text_parts).strip() or "(空消息)" + + formatted.append( + { + "sender": sender_name, + "sender_id": sender_id, + "time": msg_time.strftime("%Y-%m-%d %H:%M:%S"), + "content": content, + } + ) + + return formatted diff --git a/src/Undefined/utils/time_utils.py b/src/Undefined/utils/time_utils.py new file mode 100644 index 00000000..cd450c07 --- /dev/null +++ b/src/Undefined/utils/time_utils.py @@ -0,0 +1,47 @@ +"""时间解析工具函数""" + +from datetime import datetime + + +def parse_time_range( + start_time: str | None, end_time: str | None +) -> tuple[datetime | None, datetime | None]: + """解析时间范围,返回 datetime 对象 + + 参数: + start_time: 开始时间字符串,格式:YYYY-MM-DD HH:MM:SS + end_time: 结束时间字符串,格式:YYYY-MM-DD HH:MM:SS + + 返回: + (start_dt, end_dt) 元组,如果解析失败则对应位置为 None + """ + start_dt: datetime | None = None + end_dt: datetime | None = None + + if start_time: + try: + start_dt = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") + except ValueError: + pass + + if end_time: + try: + end_dt = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") + except ValueError: + pass + + return start_dt, end_dt + + +def format_datetime(dt: datetime | None) -> str: + """格式化 datetime 对象为字符串 + + 参数: + dt: datetime 对象 + + 返回: + 格式化的时间字符串,格式:YYYY-MM-DD HH:MM:SS + """ + if dt is None: + return "未指定" + return dt.strftime("%Y-%m-%d %H:%M:%S") From 93931006f44a076c52b32dfae6c652bbbf7e38e4 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 12 Feb 2026 22:39:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=8A=A0=E5=BC=BA=E7=BE=A4=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=88=86=E6=9E=90=E5=B7=A5=E5=85=B7=E7=9A=84=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复问题: - 在使用参数之前添加类型和范围验证 - 防止负数参数导致的错误行为(如 message_limit=-1) - 防止非整数参数导致的 TypeError - 提供清晰的错误消息 影响的工具: - analyze_member_messages - analyze_join_statistics - analyze_new_member_activity Co-Authored-By: Claude Sonnet 4.5 --- .../analyze_join_statistics/handler.py | 11 ++++++++- .../analyze_member_messages/handler.py | 23 +++++++++++++++++-- .../analyze_new_member_activity/handler.py | 23 +++++++++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py b/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py index 76779afb..9ba62679 100644 --- a/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_join_statistics/handler.py @@ -18,7 +18,6 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: end_time = args.get("end_time") include_trend = args.get("include_trend", True) include_member_list = args.get("include_member_list", False) - member_limit = min(args.get("member_limit", 20), 100) # 1. 参数验证 if not group_id: @@ -29,6 +28,16 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: except (ValueError, TypeError): return "参数类型错误:group_id 必须是整数" + # 验证和规范化数值参数 + try: + member_limit_raw = args.get("member_limit", 20) + member_limit = int(member_limit_raw) if member_limit_raw is not None else 20 + if member_limit < 0: + return "参数错误:member_limit 必须是非负整数" + member_limit = min(member_limit, 100) + except (ValueError, TypeError): + return "参数类型错误:member_limit 必须是整数" + # 2. 解析时间范围 start_dt, end_dt = parse_time_range(start_time, end_time) diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py b/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py index 65eccc62..31b8ef82 100644 --- a/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_member_messages/handler.py @@ -23,8 +23,6 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: start_time = args.get("start_time") end_time = args.get("end_time") include_messages = args.get("include_messages", False) - message_limit = min(args.get("message_limit", 20), 100) - max_history_count = min(args.get("max_history_count", 2000), 5000) # 1. 参数验证 if not group_id: @@ -38,6 +36,27 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: except (ValueError, TypeError): return "参数类型错误:group_id 和 user_id 必须是整数" + # 验证和规范化数值参数 + try: + message_limit_raw = args.get("message_limit", 20) + message_limit = int(message_limit_raw) if message_limit_raw is not None else 20 + if message_limit < 0: + return "参数错误:message_limit 必须是非负整数" + message_limit = min(message_limit, 100) + except (ValueError, TypeError): + return "参数类型错误:message_limit 必须是整数" + + try: + max_history_count_raw = args.get("max_history_count", 2000) + max_history_count = ( + int(max_history_count_raw) if max_history_count_raw is not None else 2000 + ) + if max_history_count < 0: + return "参数错误:max_history_count 必须是非负整数" + max_history_count = min(max_history_count, 5000) + except (ValueError, TypeError): + return "参数类型错误:max_history_count 必须是整数" + # 2. 解析时间范围 start_dt, end_dt = parse_time_range(start_time, end_time) diff --git a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py b/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py index df9e26ad..fbbec911 100644 --- a/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py +++ b/src/Undefined/skills/toolsets/group_analysis/analyze_new_member_activity/handler.py @@ -16,8 +16,6 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: group_id = args.get("group_id") or context.get("group_id") join_start_time = args.get("join_start_time") join_end_time = args.get("join_end_time") - max_history_count = min(args.get("max_history_count", 2000), 5000) - top_count = min(args.get("top_count", 5), 20) # 1. 参数验证 if not group_id: @@ -28,6 +26,27 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: except (ValueError, TypeError): return "参数类型错误:group_id 必须是整数" + # 验证和规范化数值参数 + try: + max_history_count_raw = args.get("max_history_count", 2000) + max_history_count = ( + int(max_history_count_raw) if max_history_count_raw is not None else 2000 + ) + if max_history_count < 0: + return "参数错误:max_history_count 必须是非负整数" + max_history_count = min(max_history_count, 5000) + except (ValueError, TypeError): + return "参数类型错误:max_history_count 必须是整数" + + try: + top_count_raw = args.get("top_count", 5) + top_count = int(top_count_raw) if top_count_raw is not None else 5 + if top_count < 0: + return "参数错误:top_count 必须是非负整数" + top_count = min(top_count, 20) + except (ValueError, TypeError): + return "参数类型错误:top_count 必须是整数" + # 2. 解析时间范围 start_dt, end_dt = parse_time_range(join_start_time, join_end_time)