Files
Bubbles/commands/handlers.py

509 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
if TYPE_CHECKING:
from .context import MessageContext
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"<think>.*?</think>", "", 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:MMdaily/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 自主决定是否调用工具。"""
chat_model = None
if hasattr(ctx, 'chat'):
chat_model = ctx.chat
elif ctx.robot and hasattr(ctx.robot, 'chat'):
chat_model = ctx.robot.chat
if not chat_model:
if ctx.logger:
ctx.logger.error("没有可用的AI模型")
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:
try:
specific_max_history = int(raw_specific_max_history)
except (TypeError, ValueError):
specific_max_history = None
if specific_max_history is not None:
specific_max_history = max(10, min(300, specific_max_history))
if specific_max_history is None:
specific_max_history = DEFAULT_CHAT_HISTORY
setattr(ctx, 'specific_max_history', specific_max_history)
# ── 引用图片特殊处理 ──────────────────────────────────
if getattr(ctx, 'is_quoted_image', False):
return _handle_quoted_image(ctx, chat_model)
# ── 构建用户消息 ──────────────────────────────────────
content = ctx.text
sender_name = ctx.sender_name
if ctx.robot and hasattr(ctx.robot, "xml_processor"):
if ctx.is_group:
msg_data = ctx.robot.xml_processor.extract_quoted_message(ctx.msg)
else:
msg_data = ctx.robot.xml_processor.extract_private_quoted_message(ctx.msg)
q_with_info = ctx.robot.xml_processor.format_message_for_ai(msg_data, sender_name)
if not q_with_info:
current_time = time_mod.strftime("%H:%M", time_mod.localtime())
q_with_info = f"[{current_time}] {sender_name}: {content or '[空内容]'}"
else:
current_time = time_mod.strftime("%H:%M", time_mod.localtime())
q_with_info = f"[{current_time}] {sender_name}: {content or '[空内容]'}"
is_auto_random_reply = getattr(ctx, 'auto_random_reply', False)
if ctx.is_group and not ctx.is_at_bot and is_auto_random_reply:
latest_message_prompt = (
"# 群聊插话提醒\n"
"你目前是在群聊里主动接话,没有人点名让你发言。\n"
"请根据下面这句(或者你任选一句)最新消息插入一条自然、不突兀的中文回复,语气放松随和即可:\n"
f"\u201c{q_with_info}\u201d\n"
"不要重复任何已知的内容,提出新的思维碰撞(例如:基于上下文的新问题、不同角度的解释等,但是不要反驳任何内容),也不要显得过于正式。"
)
else:
latest_message_prompt = (
"# 本轮需要回复的用户及其最新信息\n"
"请你基于下面这条最新收到的用户讯息(和该用户最近的历史消息),直接面向发送者进行自然的中文回复:\n"
f"\u201c{q_with_info}\u201d\n"
"请只针对该用户进行回复。"
)
# ── 构建工具列表 ──────────────────────────────────────
tools = None
tool_handler = None
if not is_auto_random_reply:
openai_tools = _get_openai_tools()
if openai_tools:
tools = openai_tools
tool_handler = _create_tool_handler(ctx)
# ── 构建系统提示 ──────────────────────────────────────
persona_text = getattr(ctx, 'persona', None)
system_prompt_override = None
tool_guidance = ""
if tools:
tool_guidance = (
"\n\n## 工具使用指引\n"
"你可以调用工具来辅助回答,以下是决策原则:\n"
"- 用户询问需要最新信息、实时数据、或你不确定的事实 → 调用 web_search\n"
"- 用户想设置/查看/删除提醒 → 调用 reminder_create / reminder_list / reminder_delete\n"
"- 用户提到之前聊过的内容、或你需要回顾更早的对话 → 调用 lookup_chat_history\n"
"- 日常闲聊、观点讨论、情感交流 → 直接回复,不需要调用任何工具\n"
"你可以在一次对话中多次调用工具,每次调用的结果会反馈给你继续推理。"
)
if persona_text:
try:
base_prompt = build_persona_system_prompt(chat_model, persona_text)
system_prompt_override = base_prompt + tool_guidance if base_prompt else tool_guidance or None
except Exception as persona_exc:
if ctx.logger:
ctx.logger.error(f"构建人设系统提示失败: {persona_exc}", exc_info=True)
system_prompt_override = tool_guidance or None
elif tool_guidance:
system_prompt_override = tool_guidance
# ── 调用 LLM ─────────────────────────────────────────
try:
if ctx.logger:
tool_names = [t["function"]["name"] for t in tools] if tools else []
ctx.logger.info(f"Agent 调用: tools={tool_names}")
rsp = chat_model.get_answer(
question=latest_message_prompt,
wxid=ctx.get_receiver(),
system_prompt_override=system_prompt_override,
specific_max_history=specific_max_history,
tools=tools,
tool_handler=tool_handler,
tool_max_iterations=20,
)
if rsp:
ctx.send_text(rsp, "")
return True
else:
if ctx.logger:
ctx.logger.error("无法从AI获得答案")
return False
except Exception as e:
if ctx.logger:
ctx.logger.error(f"获取AI回复时出错: {e}", exc_info=True)
return False
def _handle_quoted_image(ctx, chat_model) -> bool:
"""处理引用图片消息。"""
if ctx.logger:
ctx.logger.info("检测到引用图片消息,尝试处理图片内容...")
from ai_providers.ai_chatgpt import ChatGPT
support_vision = False
if isinstance(chat_model, ChatGPT):
if hasattr(chat_model, 'support_vision') and chat_model.support_vision:
support_vision = True
elif hasattr(chat_model, 'model'):
model_name = getattr(chat_model, 'model', '')
support_vision = model_name in ("gpt-4.1-mini", "gpt-4o") or "-vision" in model_name
if not support_vision:
ctx.send_text("抱歉,当前 AI 模型不支持处理图片。请联系管理员配置支持视觉的模型。")
return True
try:
temp_dir = "temp/image_cache"
os.makedirs(temp_dir, exist_ok=True)
image_path = ctx.wcf.download_image(
id=ctx.quoted_msg_id, extra=ctx.quoted_image_extra,
dir=temp_dir, timeout=30,
)
if not image_path or not os.path.exists(image_path):
ctx.send_text("抱歉,无法下载图片进行分析。")
return True
prompt = ctx.text if ctx.text and ctx.text.strip() else "请详细描述这张图片中的内容"
response = chat_model.get_image_description(image_path, prompt)
ctx.send_text(response)
try:
if os.path.exists(image_path):
os.remove(image_path)
except Exception:
pass
return True
except Exception as e:
if ctx.logger:
ctx.logger.error(f"处理引用图片出错: {e}", exc_info=True)
ctx.send_text(f"处理图片时发生错误: {str(e)}")
return True