├── README.md ├── config.toml └── main.py /README.md: -------------------------------------------------------------------------------- 1 | # ChatSummary - XYBotv2 聊天记录总结插件 📝 2 | 3 | [![Version](https://img.shields.io/github/v/release/your_username/ChatSummary)](https://github.com/your_username/ChatSummary/releases) 4 | [![Author](https://img.shields.io/badge/Author-%E8%80%81%E5%A4%8F%E7%9A%84%E9%87%91%E5%BA%93-blue)](https://github.com/your_username) 5 | [![License](https://img.shields.io/github/license/your_username/ChatSummary)](LICENSE) 6 | 7 | **本插件是 [XYBotv2](https://github.com/HenryXiaoYang/XYBotv2) 的一个插件。** 8 | 9 | 10 | 11 | ## 简介 12 | 13 | `ChatSummary` 是一款强大的聊天记录总结插件! 它可以自动分析聊天记录,并生成包含话题、参与者、时间段、过程和评价的总结报告 📊。 插件支持通过 Dify 大模型进行总结,提供更智能、更全面的分析结果 🧠。 14 | 15 | ## 功能 16 | 17 | * **聊天记录总结:** 自动分析聊天记录,生成总结报告 🧾。 18 | * **话题提取:** 提取聊天记录中的主要话题 🗣️。 19 | * **参与者识别:** 识别参与话题讨论的用户 👤。 20 | * **时间段分析:** 分析话题讨论的时间段 ⏱️。 21 | * **过程描述:** 简要描述话题讨论的过程 ✍️。 22 | * **情感评价:** 对话题讨论进行情感评价 🤔。 23 | * **Dify 集成:** 通过 Dify 大模型进行总结,提供更智能的分析结果 ✨。 24 | * **灵活的总结方式:** 25 | * **默认条数总结:** 只输入 `$总结` 或 `总结` 时,默认总结最近 100 条消息 💬。 26 | * **指定条数总结:** 输入 `$总结 200` 或 `总结 200` 时,总结最近 200 条消息 🔢。 27 | * **指定时间段总结:** 输入 `$总结 1小时` 或 `总结 1小时` 时,总结最近 1 小时的消息 🕐。 28 | * **数据库存储:** 将聊天记录存储到 SQLite 数据库中,方便后续分析和查询 💾。 29 | * **定期清理:** 定期清理数据库中的旧消息,保持数据库的精简 🧹。 30 | * **独立表格:** 为每个聊天(个人或群组)创建独立的表格,保证数据隔离和查询效率 🗂️。 31 | 32 | ## 安装 33 | 34 | 1. 确保你已经安装了 [XYBotv2]([https://github.com/HenryXiaoYang/XYBotV2])。 35 | 2. 将插件文件复制到 XYBotv2 的插件目录中 📂。 36 | 3. 安装依赖:`pip install loguru aiohttp tomli` (如果需要) 🛠️ 37 | 4. 配置插件(见配置章节)⚙️。 38 | 5. 重启你的 XYBotv2 应用程序 🔄。 39 | 40 | ## 配置 41 | 42 | 插件的配置位于 `config.toml` 文件中 📝。以下是配置示例: 43 | 44 | ```toml 45 | [ChatSummary.Dify] 46 | enable = true # 是否启用 Dify 集成 47 | api-key = "你的 Dify API 密钥" # 你的 Dify API 密钥 48 | base-url = "你的 Dify API Base URL" # 你的 Dify API Base URL 49 | http-proxy = "" # HTTP 代理服务器地址 (可选),如 "http://127.0.0.1:7890" 50 | 51 | [ChatSummary] 52 | enable = true 53 | commands = ["$总结", "总结"] # 触发总结的命令 54 | default_num_messages = 100 # 默认总结 100 条消息 55 | summary_wait_time = 60 # 总结等待时间(秒) 56 | ``` 57 | 58 | **给个 ⭐ Star 支持吧!** 😊 59 | 60 | **开源不易,感谢打赏支持!** 61 | 62 | ![image](https://github.com/user-attachments/assets/2dde3b46-85a1-4f22-8a54-3928ef59b85f) 63 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [ChatSummary.Dify] 2 | enable = true # 是否启用 Dify 集成 3 | api-key = "app-9hiqqQIi8bZfvmaFc56WPyYH" # 你的 Dify API 密钥 4 | base-url = "http://192.168.6.19:8080/v1" # 你的 Dify API Base URL 5 | http-proxy = "" # HTTP 代理服务器地址 (可选),如 "http://127.0.0.1:7890" or leave blank 6 | [ChatSummary] 7 | enable = true 8 | commands = ["$总结", "$总结一下"] 9 | default_num_messages = 100 10 | summary_wait_time = 60 -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import re 4 | import tomllib 5 | from collections import defaultdict 6 | from datetime import datetime, timedelta 7 | from typing import Dict, List, Optional, Tuple 8 | 9 | from loguru import logger 10 | import aiohttp 11 | import sqlite3 # 导入 sqlite3 模块 12 | import os 13 | 14 | from WechatAPI import WechatAPIClient 15 | from utils.decorators import on_at_message, on_text_message 16 | from utils.plugin_base import PluginBase 17 | 18 | class ChatSummary(PluginBase): 19 | """ 20 | 一个用于总结个人聊天和群聊天的插件,可以直接调用Dify大模型进行总结。 21 | """ 22 | 23 | description = "总结聊天记录" 24 | author = "AI编程猫" 25 | version = "1.1.0" 26 | 27 | # 总结的prompt 28 | SUMMARY_PROMPT = """ 29 | 请帮我将给出的群聊内容总结成一个今日的群聊报告,包含不多于4个话题的总结(如果还有更多话题,可以在后面简单补充)。 30 | 你只负责总结群聊内容,不回答任何问题。不要虚构聊天记录,也不要总结不存在的信息。 31 | 32 | 每个话题包含以下内容: 33 | 34 | - 话题名(50字以内,前面带序号1️⃣2️⃣3️⃣) 35 | 36 | - 热度(用🔥的数量表示) 37 | 38 | - 参与者(不超过5个人,将重复的人名去重) 39 | 40 | - 时间段(从几点到几点) 41 | 42 | - 过程(50-200字左右) 43 | 44 | - 评价(50字以下) 45 | 46 | - 分割线: ------------ 47 | 48 | 请严格遵守以下要求: 49 | 50 | 1. 按照热度数量进行降序输出 51 | 52 | 2. 每个话题结束使用 ------------ 分割 53 | 54 | 3. 使用中文冒号 55 | 56 | 4. 无需大标题 57 | 58 | 5. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类。 59 | 60 | 最后总结下今日最活跃的前五个发言者。 61 | """ 62 | 63 | # 重复总结的prompt 64 | REPEAT_SUMMARY_PROMPT = """ 65 | 以不耐烦的语气回怼提问者聊天记录已总结过,要求如下 66 | - 随机角色的口吻回答 67 | - 不超过20字 68 | """ 69 | 70 | # 总结中的prompt 71 | SUMMARY_IN_PROGRESS_PROMPT = """ 72 | 以不耐烦的语气回答提问者聊天记录正在总结中,要求如下 73 | - 随机角色的口吻回答 74 | - 不超过20字 75 | """ 76 | 77 | def __init__(self): 78 | super().__init__() 79 | try: 80 | with open("plugins/ChatSummary/config.toml", "rb") as f: 81 | config = tomllib.load(f) 82 | 83 | plugin_config = config["ChatSummary"] 84 | self.enable = plugin_config["enable"] 85 | self.commands = plugin_config["commands"] 86 | self.default_num_messages = plugin_config["default_num_messages"] 87 | self.summary_wait_time = plugin_config["summary_wait_time"] 88 | 89 | dify_config = plugin_config["Dify"] 90 | self.dify_enable = dify_config["enable"] 91 | self.dify_api_key = dify_config["api-key"] 92 | self.dify_base_url = dify_config["base-url"] 93 | self.http_proxy = dify_config["http-proxy"] 94 | if not self.dify_enable or not self.dify_api_key or not self.dify_base_url: 95 | logger.warning("Dify配置不完整,请检查config.toml文件") 96 | self.enable = False 97 | 98 | logger.info("ChatSummary 插件配置加载成功") 99 | except FileNotFoundError: 100 | logger.error("config.toml 配置文件未找到,插件已禁用。") 101 | self.enable = False 102 | except Exception as e: 103 | logger.exception(f"ChatSummary 插件初始化失败: {e}") 104 | self.enable = False 105 | 106 | self.summary_tasks: Dict[str, asyncio.Task] = {} # 存储正在进行的总结任务 107 | self.last_summary_time: Dict[str, datetime] = {} # 记录上次总结的时间 108 | self.chat_history: Dict[str, List[Dict]] = defaultdict(list) # 存储聊天记录 109 | self.http_session = aiohttp.ClientSession() 110 | 111 | # 数据库配置 112 | self.db_file = "chat_history.db" # 数据库文件名 113 | self.db_connection = None 114 | self.initialize_database() #初始化数据库 115 | 116 | def initialize_database(self): 117 | """初始化数据库连接""" 118 | self.db_connection = sqlite3.connect(self.db_file) 119 | logger.info("数据库连接已建立") 120 | 121 | def create_table_if_not_exists(self, chat_id: str): 122 | """为每个chat_id创建一个单独的表""" 123 | table_name = self.get_table_name(chat_id) 124 | cursor = self.db_connection.cursor() 125 | try: 126 | cursor.execute(f""" 127 | CREATE TABLE IF NOT EXISTS "{table_name}" ( 128 | id INTEGER PRIMARY KEY AUTOINCREMENT, 129 | sender_wxid TEXT NOT NULL, 130 | create_time INTEGER NOT NULL, -- 使用 INTEGER 存储时间戳 131 | content TEXT NOT NULL 132 | ) 133 | """) 134 | self.db_connection.commit() 135 | logger.info(f"表 {table_name} 创建成功") 136 | except sqlite3.Error as e: 137 | logger.error(f"创建表 {table_name} 失败:{e}") 138 | 139 | def get_table_name(self, chat_id: str) -> str: 140 | """ 141 | 生成表名,将chat_id中的特殊字符替换掉,避免SQL注入和表名错误 142 | """ 143 | return "chat_" + re.sub(r"[^a-zA-Z0-9_]", "_", chat_id) 144 | 145 | 146 | async def _summarize_chat(self, bot: WechatAPIClient, chat_id: str, limit: Optional[int] = None, duration: Optional[timedelta] = None) -> None: 147 | """ 148 | 总结聊天记录并发送结果。 149 | 150 | Args: 151 | bot: WechatAPIClient 实例. 152 | chat_id: 聊天ID (群ID或个人ID). 153 | limit: 总结的消息数量 (可选). 154 | duration: 总结的时间段 (可选). 155 | """ 156 | try: 157 | if limit: 158 | logger.info(f"开始总结 {chat_id} 的最近 {limit} 条消息") 159 | elif duration: 160 | logger.info(f"开始总结 {chat_id} 的最近 {duration} 时间段的消息") 161 | else: 162 | logger.error("limit 和 duration 都为空!") 163 | return # 理论上不应该发生 164 | 165 | # 从数据库中获取聊天记录 166 | messages_to_summarize = self.get_messages_from_db(chat_id, limit, duration) 167 | 168 | if not messages_to_summarize: 169 | try: 170 | await bot.send_text_message(chat_id, "没有足够的聊天记录可以总结。") 171 | except AttributeError as e: 172 | logger.error(f"发送消息失败 (没有 send_text_message 方法): {e}") 173 | return 174 | except Exception as e: 175 | logger.exception(f"发送消息失败: {e}") 176 | return 177 | 178 | # 获取所有发言者的 wxid 179 | wxids = set(msg['sender_wxid'] for msg in messages_to_summarize) # 注意这里键名改成小写了 180 | nicknames = {} 181 | for wxid in wxids: 182 | try: 183 | nickname = await bot.get_nickname(wxid) 184 | nicknames[wxid] = nickname 185 | except Exception as e: 186 | logger.exception(f"获取用户 {wxid} 昵称失败: {e}") 187 | nicknames[wxid] = wxid # 获取昵称失败,使用 wxid 代替 188 | 189 | # 提取消息内容,并替换成昵称 190 | text_to_summarize = "\n".join( 191 | [f"{nicknames.get(msg['sender_wxid'], msg['sender_wxid'])} ({datetime.fromtimestamp(msg['create_time']).strftime('%H:%M:%S')}): {msg['content']}" # 注意键名改成小写了 192 | for msg in messages_to_summarize] 193 | ) 194 | 195 | # 调用 Dify API 进行总结 196 | summary = await self._get_summary_from_dify(chat_id, text_to_summarize) 197 | 198 | try: 199 | await bot.send_text_message(chat_id, f"-----聊天总结-----\n{summary}") 200 | except AttributeError as e: 201 | logger.error(f"发送消息失败 (没有 send_text_message 方法): {e}") 202 | return 203 | except Exception as e: 204 | logger.exception(f"发送消息失败: {e}") 205 | return 206 | 207 | self.last_summary_time[chat_id] = datetime.now() # 更新上次总结时间 208 | logger.info(f"{chat_id} 的总结完成") 209 | 210 | except Exception as e: 211 | logger.exception(f"总结 {chat_id} 发生错误: {e}") 212 | try: 213 | await bot.send_text_message(chat_id, f"总结时发生错误: {e}") 214 | except AttributeError as e: 215 | logger.error(f"发送消息失败 (没有 send_text_message 方法): {e}") 216 | return 217 | except Exception as e: 218 | logger.exception(f"发送消息失败: {e}") 219 | return 220 | finally: 221 | if chat_id in self.summary_tasks: 222 | del self.summary_tasks[chat_id] # 移除任务 223 | 224 | async def _get_summary_from_dify(self, chat_id: str, text: str) -> str: 225 | """ 226 | 使用 Dify API 获取总结。 227 | 228 | Args: 229 | chat_id: 聊天ID (群ID或个人ID). 230 | text: 需要总结的文本. 231 | 232 | Returns: 233 | 总结后的文本. 234 | """ 235 | try: 236 | headers = {"Authorization": f"Bearer {self.dify_api_key}", 237 | "Content-Type": "application/json"} 238 | payload = json.dumps({ 239 | "inputs": {}, 240 | "query": f"{self.SUMMARY_PROMPT}\n\n{text}", 241 | "response_mode": "blocking", # 必须是blocking 242 | "conversation_id": None, 243 | "user": chat_id, 244 | "files": [], 245 | "auto_generate_name": False, 246 | }) 247 | url = f"{self.dify_base_url}/chat-messages" 248 | async with self.http_session.post(url=url, headers=headers, data=payload, proxy = self.http_proxy) as resp: 249 | if resp.status == 200: 250 | resp_json = await resp.json() 251 | summary = resp_json.get("answer", "") 252 | logger.info(f"成功从 Dify API 获取总结: {summary}") 253 | return summary 254 | else: 255 | error_msg = await resp.text() 256 | logger.error(f"调用 Dify API 失败: {resp.status} - {error_msg}") 257 | return f"总结失败,Dify API 错误: {resp.status} - {error_msg}" 258 | except Exception as e: 259 | logger.exception(f"调用 Dify API 失败: {e}") 260 | return "总结失败,请稍后重试。" # 返回错误信息 261 | 262 | def _extract_duration(self, text: str) -> Optional[timedelta]: 263 | """ 264 | 从文本中提取要总结的时间段。 265 | 266 | Args: 267 | text: 包含命令的文本。 268 | 269 | Returns: 270 | 要总结的时间段,如果提取失败则返回 None。 271 | """ 272 | match = re.search(r'(\d+)\s*(小时|分钟|天)', text) 273 | if not match: 274 | return None 275 | 276 | amount = int(match.group(1)) 277 | unit = match.group(2) 278 | 279 | if unit == '小时': 280 | return timedelta(hours=amount) 281 | elif unit == '分钟': 282 | return timedelta(minutes=amount) 283 | elif unit == '天': 284 | return timedelta(days=amount) 285 | else: 286 | return None 287 | 288 | def _extract_num_messages(self, text: str) -> int: 289 | """ 290 | 从文本中提取要总结的消息数量。 291 | 292 | Args: 293 | text: 包含命令的文本。 294 | 295 | Returns: 296 | 要总结的消息数量,如果提取失败则返回 default_num_messages。 297 | """ 298 | try: 299 | match = re.search(r'(\d+)', text) 300 | if match: 301 | return int(match.group(1)) 302 | return self.default_num_messages # 提取不到时返回默认值 303 | except ValueError: 304 | logger.warning(f"无法从文本中提取消息数量: {text}") 305 | return self.default_num_messages # 提取不到时返回默认值 306 | 307 | @on_text_message 308 | async def handle_text_message(self, bot: WechatAPIClient, message: Dict) -> bool: # 添加类型提示和返回值 309 | """处理文本消息,判断是否需要触发总结。""" 310 | if not self.enable: 311 | return True # 插件未启用,允许其他插件处理 312 | 313 | chat_id = message["FromWxid"] 314 | sender_wxid = message["SenderWxid"] 315 | content = message["Content"] 316 | is_group = message["IsGroup"] 317 | create_time = message["CreateTime"] 318 | 319 | # 1. 创建表 (如果不存在) 320 | self.create_table_if_not_exists(chat_id) 321 | 322 | # 2. 保存聊天记录到数据库 323 | self.save_message_to_db(chat_id, sender_wxid, create_time, content) 324 | 325 | # 3. 记录聊天历史 (可选,如果你还需要在内存中保留一份) 326 | # self.chat_history[chat_id].append(message) 327 | 328 | # 4. 检查是否为总结命令 329 | if any(cmd in content for cmd in self.commands): 330 | # 4.1 提取时间范围 331 | duration = self._extract_duration(content) 332 | # 4.2 提取消息数量 333 | limit = None 334 | if not duration: #如果没有时间范围,就提取消息数量 335 | limit = self._extract_num_messages(content) 336 | 337 | 338 | # 4.3 检查是否正在进行总结 339 | if chat_id in self.summary_tasks: 340 | try: 341 | await bot.send_text_message(chat_id, self.SUMMARY_IN_PROGRESS_PROMPT) 342 | return False # 正在总结中,阻止其他插件处理 343 | except AttributeError as e: 344 | logger.error(f"发送消息失败 (没有 send_text_message 方法): {e}") 345 | return True # 允许其他插件处理,因为发送消息失败了 346 | except Exception as e: 347 | logger.exception(f"发送消息失败: {e}") 348 | return True # 允许其他插件处理,因为发送消息失败了 349 | 350 | # 4.4 创建总结任务 351 | self.summary_tasks[chat_id] = asyncio.create_task( 352 | self._summarize_chat(bot, chat_id, limit=limit, duration=duration) # 传递 limit 和 duration 353 | ) 354 | if duration: 355 | logger.info(f"创建 {chat_id} 的总结任务,总结最近 {duration} 的消息") 356 | else: 357 | logger.info(f"创建 {chat_id} 的总结任务,总结最近 {limit} 条消息") 358 | return False # 已创建总结任务,阻止其他插件处理 359 | return True # 不是总结命令,允许其他插件处理 360 | 361 | def save_message_to_db(self, chat_id: str, sender_wxid: str, create_time: int, content: str): 362 | """将消息保存到数据库""" 363 | table_name = self.get_table_name(chat_id) 364 | try: 365 | cursor = self.db_connection.cursor() 366 | cursor.execute(f""" 367 | INSERT INTO "{table_name}" (sender_wxid, create_time, content) 368 | VALUES (?, ?, ?) 369 | """, (sender_wxid, create_time, content)) 370 | self.db_connection.commit() 371 | logger.debug(f"消息保存到表 {table_name}: sender_wxid={sender_wxid}, create_time={create_time}") 372 | except sqlite3.Error as e: 373 | logger.exception(f"保存消息到表 {table_name} 失败: {e}") 374 | 375 | def get_messages_from_db(self, chat_id: str, limit: Optional[int] = None, duration: Optional[timedelta] = None) -> List[Dict]: 376 | """从数据库获取消息,同时支持按条数和按时间范围获取""" 377 | table_name = self.get_table_name(chat_id) 378 | 379 | try: 380 | cursor = self.db_connection.cursor() 381 | if duration: 382 | cutoff_time = datetime.now() - duration 383 | cutoff_timestamp = int(cutoff_time.timestamp()) 384 | cursor.execute(f""" 385 | SELECT sender_wxid, create_time, content 386 | FROM "{table_name}" 387 | WHERE create_time >= ? 388 | ORDER BY create_time DESC 389 | """, (cutoff_timestamp,)) 390 | 391 | elif limit: 392 | cursor.execute(f""" 393 | SELECT sender_wxid, create_time, content 394 | FROM "{table_name}" 395 | ORDER BY create_time DESC 396 | LIMIT ? 397 | """, (limit,)) 398 | else: 399 | return [] #避免不传limit和duration的情况 400 | rows = cursor.fetchall() 401 | # 将结果转换为字典列表,方便后续使用 402 | messages = [] 403 | for row in rows: 404 | messages.append({ 405 | 'sender_wxid': row[0], 406 | 'create_time': row[1], 407 | 'content': row[2] 408 | }) 409 | if duration: 410 | logger.debug(f"从表 {table_name} 获取消息: duration={duration}, 数量={len(messages)}") 411 | else: 412 | logger.debug(f"从表 {table_name} 获取消息: limit={limit}, 数量={len(messages)}") 413 | return messages 414 | except sqlite3.Error as e: 415 | logger.exception(f"从表 {table_name} 获取消息失败: {e}") 416 | return [] 417 | 418 | async def clear_old_messages(self): 419 | """定期清理旧消息""" 420 | while True: 421 | await asyncio.sleep(60 * 60 * 24) # 每天检查一次 422 | try: 423 | cutoff_time = datetime.now() - timedelta(days=3) # 3天前 424 | cutoff_timestamp = int(cutoff_time.timestamp()) 425 | 426 | cursor = self.db_connection.cursor() 427 | 428 | # 获取所有表名 429 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") 430 | tables = [row[0] for row in cursor.fetchall() if row[0].startswith("chat_")] #只清理chat_开头的表 431 | 432 | for table in tables: 433 | try: 434 | cursor.execute(f""" 435 | DELETE FROM "{table}" 436 | WHERE create_time < ? 437 | """, (cutoff_timestamp,)) 438 | self.db_connection.commit() 439 | logger.info(f"已清理表 {table} 中 {cutoff_timestamp} 之前的旧消息") 440 | except sqlite3.Error as e: 441 | logger.exception(f"清理表 {table} 失败: {e}") 442 | 443 | except Exception as e: 444 | logger.exception(f"清理旧消息失败: {e}") 445 | 446 | async def close(self): 447 | """插件关闭时,取消所有未完成的总结任务。""" 448 | logger.info("Closing ChatSummary plugin") 449 | for chat_id, task in self.summary_tasks.items(): 450 | if not task.done(): 451 | logger.info(f"Cancelling summary task for {chat_id}") 452 | task.cancel() 453 | try: 454 | await task 455 | except asyncio.CancelledError: 456 | logger.info(f"Summary task for {chat_id} was cancelled") 457 | except Exception as e: 458 | logger.exception(f"Error while cancelling summary task for {chat_id}: {e}") 459 | if self.http_session: 460 | await self.http_session.close() 461 | logger.info("Aiohttp session closed") 462 | 463 | # 关闭数据库连接 464 | if self.db_connection: 465 | self.db_connection.close() 466 | logger.info("数据库连接已关闭") 467 | 468 | logger.info("ChatSummary plugin closed") 469 | 470 | async def start(self): 471 | """启动插件时启动清理旧消息的任务""" 472 | asyncio.create_task(self.clear_old_messages()) #启动定时清理任务 --------------------------------------------------------------------------------