From 2881f5d5c899b49796c297aec001e343a4825b65 Mon Sep 17 00:00:00 2001 From: zihanjian Date: Wed, 4 Feb 2026 19:02:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(handlers):=20=E6=B7=BB=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=87=BD=E6=95=B0=E9=9B=86=E5=B9=B6=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commands/handlers.py | 334 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 321 insertions(+), 13 deletions(-) diff --git a/commands/handlers.py b/commands/handlers.py index 7d05107..b351758 100644 --- a/commands/handlers.py +++ b/commands/handlers.py @@ -1,5 +1,9 @@ +import json +import logging import os +import re import time as time_mod +from datetime import datetime from typing import Optional, Match, TYPE_CHECKING from function.func_persona import build_persona_system_prompt @@ -7,14 +11,322 @@ from function.func_persona import build_persona_system_prompt if TYPE_CHECKING: from .context import MessageContext -DEFAULT_CHAT_HISTORY = 30 +logger = logging.getLogger(__name__) +DEFAULT_CHAT_HISTORY = 30 +DEFAULT_VISIBLE_LIMIT = 30 + + +# ══════════════════════════════════════════════════════════ +# 工具 handler 函数 +# ══════════════════════════════════════════════════════════ + +def _web_search(ctx, query: str = "", deep_research: bool = False, **_) -> str: + perplexity_instance = getattr(ctx.robot, "perplexity", None) + if not perplexity_instance: + return json.dumps({"error": "Perplexity 搜索功能不可用,未配置或未初始化"}, ensure_ascii=False) + if not query: + return json.dumps({"error": "请提供搜索关键词"}, ensure_ascii=False) + try: + response = perplexity_instance.get_answer(query, ctx.get_receiver(), deep_research=deep_research) + if not response: + return json.dumps({"error": "搜索无结果"}, ensure_ascii=False) + cleaned = re.sub(r".*?", "", response, flags=re.DOTALL).strip() + return json.dumps({"result": cleaned or response}, ensure_ascii=False) + except Exception as e: + return json.dumps({"error": f"搜索失败: {e}"}, ensure_ascii=False) + + +def _reminder_create(ctx, type: str = "once", time: str = "", + content: str = "", weekday: int = None, **_) -> str: + if not hasattr(ctx.robot, "reminder_manager"): + return json.dumps({"error": "提醒管理器未初始化"}, ensure_ascii=False) + if not time or not content: + return json.dumps({"error": "缺少必要字段: time 和 content"}, ensure_ascii=False) + if len(content.strip()) < 2: + return json.dumps({"error": "提醒内容太短"}, ensure_ascii=False) + + if type == "once": + parsed_dt = None + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S"): + try: + parsed_dt = datetime.strptime(time, fmt) + break + except ValueError: + continue + if not parsed_dt: + return json.dumps({"error": f"once 类型时间格式应为 YYYY-MM-DD HH:MM,收到: {time}"}, ensure_ascii=False) + if parsed_dt < datetime.now(): + return json.dumps({"error": f"时间 {time} 已过去,请使用未来的时间"}, ensure_ascii=False) + time = parsed_dt.strftime("%Y-%m-%d %H:%M") + elif type in ("daily", "weekly"): + parsed_time = None + for fmt in ("%H:%M", "%H:%M:%S"): + try: + parsed_time = datetime.strptime(time, fmt) + break + except ValueError: + continue + if not parsed_time: + return json.dumps({"error": f"daily/weekly 类型时间格式应为 HH:MM,收到: {time}"}, ensure_ascii=False) + time = parsed_time.strftime("%H:%M") + else: + return json.dumps({"error": f"不支持的提醒类型: {type}"}, ensure_ascii=False) + + if type == "weekly" and (weekday is None or not (isinstance(weekday, int) and 0 <= weekday <= 6)): + return json.dumps({"error": "weekly 类型需要 weekday 参数 (0=周一 … 6=周日)"}, ensure_ascii=False) + + data = {"type": type, "time": time, "content": content, "extra": {}} + if weekday is not None: + data["weekday"] = weekday + + roomid = ctx.msg.roomid if ctx.is_group else None + success, result = ctx.robot.reminder_manager.add_reminder(ctx.msg.sender, data, roomid=roomid) + if success: + type_label = {"once": "一次性", "daily": "每日", "weekly": "每周"}.get(type, type) + return json.dumps({"success": True, "id": result, + "message": f"已创建{type_label}提醒: {time} - {content}"}, ensure_ascii=False) + return json.dumps({"success": False, "error": result}, ensure_ascii=False) + + +def _reminder_list(ctx, **_) -> str: + if not hasattr(ctx.robot, "reminder_manager"): + return json.dumps({"error": "提醒管理器未初始化"}, ensure_ascii=False) + reminders = ctx.robot.reminder_manager.list_reminders(ctx.msg.sender) + if not reminders: + return json.dumps({"reminders": [], "message": "当前没有任何提醒"}, ensure_ascii=False) + return json.dumps({"reminders": reminders, "count": len(reminders)}, ensure_ascii=False) + + +def _reminder_delete(ctx, reminder_id: str = "", delete_all: bool = False, **_) -> str: + if not hasattr(ctx.robot, "reminder_manager"): + return json.dumps({"error": "提醒管理器未初始化"}, ensure_ascii=False) + if delete_all: + success, message, count = ctx.robot.reminder_manager.delete_all_reminders(ctx.msg.sender) + return json.dumps({"success": success, "message": message, "deleted_count": count}, ensure_ascii=False) + if not reminder_id: + return json.dumps({"error": "请提供 reminder_id,或设置 delete_all=true 删除全部"}, ensure_ascii=False) + success, message = ctx.robot.reminder_manager.delete_reminder(ctx.msg.sender, reminder_id) + return json.dumps({"success": success, "message": message}, ensure_ascii=False) + + +def _lookup_chat_history(ctx, mode: str = "", keywords: list = None, + start_offset: int = None, end_offset: int = None, + start_time: str = None, end_time: str = None, **_) -> str: + message_summary = getattr(ctx.robot, "message_summary", None) if ctx.robot else None + if not message_summary: + return json.dumps({"error": "消息历史功能不可用"}, ensure_ascii=False) + + chat_id = ctx.get_receiver() + visible_limit = DEFAULT_VISIBLE_LIMIT + raw = getattr(ctx, "specific_max_history", None) + if raw is not None: + try: + visible_limit = int(raw) + except (TypeError, ValueError): + pass + + mode = (mode or "").strip().lower() + if not mode: + if start_time and end_time: + mode = "time" + elif start_offset is not None and end_offset is not None: + mode = "range" + else: + mode = "keywords" + + if mode == "keywords": + if isinstance(keywords, str): + keywords = [keywords] + elif not isinstance(keywords, list): + keywords = [] + cleaned = [] + seen = set() + for kw in keywords: + if kw is None: + continue + s = str(kw).strip() + if s and (len(s) > 1 or s.isdigit()): + low = s.lower() + if low not in seen: + seen.add(low) + cleaned.append(s) + if not cleaned: + return json.dumps({"error": "未提供有效关键词", "results": []}, ensure_ascii=False) + search_results = message_summary.search_messages_with_context( + chat_id=chat_id, keywords=cleaned, context_window=10, + max_groups=20, exclude_recent=visible_limit, + ) + segments, lines_seen = [], set() + for seg in search_results: + formatted = [l for l in seg.get("formatted_messages", []) if l not in lines_seen] + lines_seen.update(formatted) + if formatted: + segments.append({"matched_keywords": seg.get("matched_keywords", []), "messages": formatted}) + payload = {"segments": segments, "returned_groups": len(segments), "keywords": cleaned} + if not segments: + payload["notice"] = "未找到匹配的消息。" + return json.dumps(payload, ensure_ascii=False) + + if mode == "range": + if start_offset is None or end_offset is None: + return json.dumps({"error": "range 模式需要 start_offset 和 end_offset"}, ensure_ascii=False) + try: + start_offset, end_offset = int(start_offset), int(end_offset) + except (TypeError, ValueError): + return json.dumps({"error": "start_offset 和 end_offset 必须是整数"}, ensure_ascii=False) + if start_offset <= visible_limit or end_offset <= visible_limit: + return json.dumps({"error": f"偏移量必须大于 {visible_limit} 以排除当前可见消息"}, ensure_ascii=False) + if start_offset > end_offset: + start_offset, end_offset = end_offset, start_offset + result = message_summary.get_messages_by_reverse_range( + chat_id=chat_id, start_offset=start_offset, end_offset=end_offset, + ) + payload = { + "start_offset": result.get("start_offset"), "end_offset": result.get("end_offset"), + "messages": result.get("messages", []), "returned_count": result.get("returned_count", 0), + "total_messages": result.get("total_messages", 0), + } + if payload["returned_count"] == 0: + payload["notice"] = "请求范围内没有消息。" + return json.dumps(payload, ensure_ascii=False) + + if mode == "time": + if not start_time or not end_time: + return json.dumps({"error": "time 模式需要 start_time 和 end_time"}, ensure_ascii=False) + time_lines = message_summary.get_messages_by_time_window( + chat_id=chat_id, start_time=start_time, end_time=end_time, + ) + payload = {"start_time": start_time, "end_time": end_time, + "messages": time_lines, "returned_count": len(time_lines)} + if not time_lines: + payload["notice"] = "该时间范围内没有消息。" + return json.dumps(payload, ensure_ascii=False) + + return json.dumps({"error": f"不支持的模式: {mode}"}, ensure_ascii=False) + + +# ══════════════════════════════════════════════════════════ +# 工具注册表 +# ══════════════════════════════════════════════════════════ + +TOOLS = { + "web_search": { + "handler": _web_search, + "description": "在网络上搜索信息。用于回答需要最新数据、实时信息或你不确定的事实性问题。deep_research 仅在问题非常复杂、需要深度研究时才开启。", + "status_text": "正在联网搜索: ", + "status_arg": "query", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "搜索关键词或问题"}, + "deep_research": {"type": "boolean", "description": "是否启用深度研究模式(耗时较长,仅用于复杂问题)"}, + }, + "required": ["query"], + "additionalProperties": False, + }, + }, + "reminder_create": { + "handler": _reminder_create, + "description": "创建提醒。支持 once(一次性)、daily(每日)、weekly(每周) 三种类型。当前时间已在对话上下文中提供,请据此计算目标时间。", + "status_text": "正在设置提醒...", + "parameters": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["once", "daily", "weekly"], "description": "提醒类型"}, + "time": {"type": "string", "description": "once → YYYY-MM-DD HH:MM;daily/weekly → HH:MM"}, + "content": {"type": "string", "description": "提醒内容"}, + "weekday": {"type": "integer", "description": "仅 weekly 需要。0=周一 … 6=周日"}, + }, + "required": ["type", "time", "content"], + "additionalProperties": False, + }, + }, + "reminder_list": { + "handler": _reminder_list, + "description": "查看当前用户的所有提醒列表。", + "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + "reminder_delete": { + "handler": _reminder_delete, + "description": "删除提醒。需要先调用 reminder_list 获取 ID,再用 reminder_id 精确删除;或设置 delete_all=true 一次性删除全部。", + "parameters": { + "type": "object", + "properties": { + "reminder_id": {"type": "string", "description": "要删除的提醒完整 ID"}, + "delete_all": {"type": "boolean", "description": "是否删除该用户全部提醒"}, + }, + "additionalProperties": False, + }, + }, + "lookup_chat_history": { + "handler": _lookup_chat_history, + "description": "查询聊天历史记录。你当前只能看到最近的消息,调用此工具可以回溯更早的上下文。支持 keywords/range/time 三种模式。", + "status_text": "正在翻阅聊天记录: ", + "status_arg": "keywords", + "parameters": { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["keywords", "range", "time"], "description": "查询模式"}, + "keywords": {"type": "array", "items": {"type": "string"}, "description": "mode=keywords 时的搜索关键词"}, + "start_offset": {"type": "integer", "description": "mode=range 时的起始偏移(从最新消息倒数)"}, + "end_offset": {"type": "integer", "description": "mode=range 时的结束偏移"}, + "start_time": {"type": "string", "description": "mode=time 时的开始时间 (YYYY-MM-DD HH:MM)"}, + "end_time": {"type": "string", "description": "mode=time 时的结束时间 (YYYY-MM-DD HH:MM)"}, + }, + "additionalProperties": False, + }, + }, +} + + +def _get_openai_tools(): + return [ + {"type": "function", "function": {"name": n, "description": s["description"], "parameters": s["parameters"]}} + for n, s in TOOLS.items() + ] + + +def _create_tool_handler(ctx): + def _send_status(spec, arguments): + status = spec.get("status_text", "") + if not status: + return + try: + arg_name = spec.get("status_arg", "") + if arg_name: + val = arguments.get(arg_name) + if val is not None: + if isinstance(val, list): + val = "、".join(str(k) for k in val[:3]) + status = f"{status}{val}" + ctx.send_text(status, record_message=False) + except Exception: + pass + + def handler(tool_name, arguments): + spec = TOOLS.get(tool_name) + if not spec: + return json.dumps({"error": f"Unknown tool: {tool_name}"}, ensure_ascii=False) + _send_status(spec, arguments) + try: + result = spec["handler"](ctx, **arguments) + if not isinstance(result, str): + result = json.dumps(result, ensure_ascii=False) + return result + except Exception as e: + logger.error(f"工具 {tool_name} 执行失败: {e}", exc_info=True) + return json.dumps({"error": str(e)}, ensure_ascii=False) + + return handler + + +# ══════════════════════════════════════════════════════════ +# Agent 入口 +# ══════════════════════════════════════════════════════════ def handle_chitchat(ctx: 'MessageContext', match: Optional[Match]) -> bool: - """ - Agent 入口 —— 处理用户消息,LLM 自主决定是否调用工具。 - """ - # 获取对应的AI模型 + """Agent 入口 —— 处理用户消息,LLM 自主决定是否调用工具。""" chat_model = None if hasattr(ctx, 'chat'): chat_model = ctx.chat @@ -27,7 +339,7 @@ def handle_chitchat(ctx: 'MessageContext', match: Optional[Match]) -> bool: ctx.send_text("抱歉,我现在无法进行对话。") return False - # 获取特定的历史消息数量限制 + # 历史消息数量限制 raw_specific_max_history = getattr(ctx, 'specific_max_history', None) specific_max_history = None if raw_specific_max_history is not None: @@ -84,20 +396,16 @@ def handle_chitchat(ctx: 'MessageContext', match: Optional[Match]) -> bool: tools = None tool_handler = None - # 插嘴模式不使用工具,减少 token 消耗 if not is_auto_random_reply: - import skills - - openai_tools = skills.get_openai_tools() + openai_tools = _get_openai_tools() if openai_tools: tools = openai_tools - tool_handler = skills.create_handler(ctx) + tool_handler = _create_tool_handler(ctx) # ── 构建系统提示 ────────────────────────────────────── persona_text = getattr(ctx, 'persona', None) system_prompt_override = None - # 工具使用指引 tool_guidance = "" if tools: tool_guidance = ( @@ -121,7 +429,7 @@ def handle_chitchat(ctx: 'MessageContext', match: Optional[Match]) -> bool: elif tool_guidance: system_prompt_override = tool_guidance - # ── 调用 LLM(Agent 循环在 _execute_with_tools 中)── + # ── 调用 LLM ───────────────────────────────────────── try: if ctx.logger: tool_names = [t["function"]["name"] for t in tools] if tools else []