This commit is contained in:
zihanjian
2025-09-24 20:01:22 +08:00
parent a1b3799c0c
commit 33731cb83b
34 changed files with 1982 additions and 939 deletions

View File

@@ -0,0 +1,3 @@
"""
Function Call 系统核心模块
"""

180
function_calls/handlers.py Normal file
View File

@@ -0,0 +1,180 @@
"""Function Call handlers built on top of structured services."""
from __future__ import annotations
import logging
from commands.context import MessageContext
from .models import (
WeatherArgs,
NewsArgs,
ReminderArgs,
ReminderListArgs,
ReminderDeleteArgs,
PerplexityArgs,
HelpArgs,
SummaryArgs,
ClearMessagesArgs,
InsultArgs,
)
from .registry import tool_function
from .spec import FunctionResult
from .services import (
build_help_text,
build_insult,
clear_group_messages,
create_reminder,
delete_reminder,
get_news_digest,
get_weather_report,
list_reminders,
run_perplexity,
summarize_messages,
)
logger = logging.getLogger(__name__)
@tool_function(
name="weather_query",
description="查询城市天气预报",
examples=["北京天气怎么样", "上海天气", "深圳明天会下雨吗"],
scope="both",
require_at=True,
)
def handle_weather(ctx: MessageContext, args: WeatherArgs) -> FunctionResult:
result = get_weather_report(args.city)
at = ctx.msg.sender if ctx.is_group else ""
return FunctionResult(handled=True, messages=[result.message], at=at if at else "")
@tool_function(
name="news_query",
description="获取今日新闻",
examples=["看看今天的新闻", "今日要闻", "新闻"],
scope="both",
require_at=True,
)
def handle_news(ctx: MessageContext, args: NewsArgs) -> FunctionResult:
result = get_news_digest()
at = ctx.msg.sender if ctx.is_group else ""
return FunctionResult(handled=True, messages=[result.message], at=at if at else "")
@tool_function(
name="reminder_set",
description="设置提醒",
examples=["提醒我明天下午3点开会", "每天早上8点提醒我吃早餐"],
scope="both",
require_at=True,
)
def handle_reminder_set(ctx: MessageContext, args: ReminderArgs) -> FunctionResult:
manager = getattr(ctx.robot, 'reminder_manager', None)
at = ctx.msg.sender if ctx.is_group else ""
if not manager:
return FunctionResult(handled=True, messages=["❌ 内部错误:提醒管理器未初始化。"], at=at)
service_result = create_reminder(
manager=manager,
sender_wxid=ctx.msg.sender,
data=args.model_dump(),
roomid=ctx.msg.roomid if ctx.is_group else None,
)
return FunctionResult(handled=True, messages=service_result.messages, at=at if at else "")
@tool_function(
name="reminder_list",
description="查看所有提醒",
examples=["查看我的提醒", "我有哪些提醒", "提醒列表"],
scope="both",
require_at=True,
)
def handle_reminder_list(ctx: MessageContext, args: ReminderListArgs) -> FunctionResult:
manager = getattr(ctx.robot, 'reminder_manager', None)
at = ctx.msg.sender if ctx.is_group else ""
if not manager:
return FunctionResult(handled=True, messages=["❌ 内部错误:提醒管理器未初始化。"], at=at)
service_result = list_reminders(manager, ctx.msg.sender, ctx.all_contacts)
return FunctionResult(handled=True, messages=service_result.messages, at=at if at else "")
@tool_function(
name="reminder_delete",
description="删除提醒",
examples=["删除开会的提醒", "取消明天的提醒"],
scope="both",
require_at=True,
)
def handle_reminder_delete(ctx: MessageContext, args: ReminderDeleteArgs) -> FunctionResult:
manager = getattr(ctx.robot, 'reminder_manager', None)
at = ctx.msg.sender if ctx.is_group else ""
if not manager:
return FunctionResult(handled=True, messages=["❌ 内部错误:提醒管理器未初始化。"], at=at)
service_result = delete_reminder(manager, ctx.msg.sender, args.reminder_id)
return FunctionResult(handled=True, messages=service_result.messages, at=at if at else "")
@tool_function(
name="perplexity_search",
description="使用Perplexity进行深度搜索查询",
examples=["搜索Python最新特性", "查查机器学习教程", "ask什么是量子计算"],
scope="both",
require_at=True,
)
def handle_perplexity_search(ctx: MessageContext, args: PerplexityArgs) -> FunctionResult:
service_result = run_perplexity(ctx, args.query)
if service_result.handled_externally:
return FunctionResult(handled=True, messages=[])
at = ctx.msg.sender if ctx.is_group else ""
return FunctionResult(handled=True, messages=service_result.messages, at=at if at else "")
@tool_function(
name="help",
description="显示机器人帮助信息",
examples=["help", "帮助", "指令"],
scope="both",
require_at=False,
)
def handle_help(ctx: MessageContext, args: HelpArgs) -> FunctionResult:
help_text = build_help_text()
return FunctionResult(handled=True, messages=[help_text])
@tool_function(
name="summary",
description="总结群聊最近的消息",
examples=["summary", "总结"],
scope="group",
require_at=True,
)
def handle_summary(ctx: MessageContext, args: SummaryArgs) -> FunctionResult:
result = summarize_messages(ctx)
return FunctionResult(handled=True, messages=[result.message])
@tool_function(
name="clear_messages",
description="清除群聊历史消息记录",
examples=["clearmessages", "清除历史"],
scope="group",
require_at=True,
)
def handle_clear_messages(ctx: MessageContext, args: ClearMessagesArgs) -> FunctionResult:
result = clear_group_messages(ctx)
return FunctionResult(handled=True, messages=[result.message])
@tool_function(
name="insult",
description="骂指定用户(仅限群聊)",
examples=["骂一下@某人"],
scope="group",
require_at=True,
)
def handle_insult(ctx: MessageContext, args: InsultArgs) -> FunctionResult:
result = build_insult(ctx, args.target_user)
return FunctionResult(handled=True, messages=[result.message])

View File

@@ -0,0 +1,10 @@
"""
初始化所有Function Call处理器
导入这个模块会自动注册所有处理器到全局注册表
"""
from . import handlers # 导入handlers模块会自动执行所有装饰器注册
# 可以在这里添加一些初始化日志
import logging
logger = logging.getLogger(__name__)
logger.info("Function Call 处理器初始化完成")

263
function_calls/llm.py Normal file
View File

@@ -0,0 +1,263 @@
"""LLM function-call orchestration utilities."""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
from commands.context import MessageContext
from .spec import FunctionResult, FunctionSpec
logger = logging.getLogger(__name__)
@dataclass
class LLMRunResult:
"""Result of the LLM routing pipeline."""
handled: bool
final_response: Optional[str] = None
error: Optional[str] = None
class FunctionCallLLM:
"""Coordinate function-call capable models with router handlers."""
def __init__(self, max_function_rounds: int = 5) -> None:
self.logger = logger
self.max_function_rounds = max_function_rounds
def run(
self,
ctx: MessageContext,
functions: Dict[str, FunctionSpec],
executor: Callable[[FunctionSpec, Dict[str, Any]], FunctionResult],
formatter: Callable[[FunctionResult], str],
) -> LLMRunResult:
"""Execute the function-call loop and return the final assistant response."""
if not ctx.text:
return LLMRunResult(handled=False)
chat_model = getattr(ctx, "chat", None)
if not chat_model and ctx.robot:
chat_model = getattr(ctx.robot, "chat", None)
if not chat_model:
self.logger.error("无可用的AI模型")
return LLMRunResult(handled=False, error="no_model")
try:
if hasattr(chat_model, "call_with_functions"):
return self._run_native_loop(ctx, chat_model, functions, executor, formatter)
return self._run_prompt_loop(ctx, chat_model, functions, executor)
except Exception as exc: # pragma: no cover - safeguard
self.logger.error(f"LLM 调用失败: {exc}")
return LLMRunResult(handled=False, error=str(exc))
# ---------------------------------------------------------------------
# Native function-call workflow
# ---------------------------------------------------------------------
def _run_native_loop(
self,
ctx: MessageContext,
chat_model: Any,
functions: Dict[str, FunctionSpec],
executor: Callable[[FunctionSpec, Dict[str, Any]], FunctionResult],
formatter: Callable[[FunctionResult], str],
) -> LLMRunResult:
openai_functions = self._build_functions_for_openai(functions)
messages: List[Dict[str, Any]] = []
system_prompt = (
"You are an assistant that can call tools. "
"When you invoke a function, wait for the tool response before replying to the user. "
"Only deliver a final answer once you have enough information."
)
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": ctx.text})
for round_index in range(self.max_function_rounds):
response = chat_model.call_with_functions(
messages=messages,
functions=openai_functions,
wxid=ctx.get_receiver(),
)
if not getattr(response, "choices", None):
self.logger.warning("函数调用返回空响应")
return LLMRunResult(handled=False)
message = response.choices[0].message
assistant_entry = self._convert_assistant_message(message)
messages.append(assistant_entry)
tool_calls = getattr(message, "tool_calls", None) or []
if tool_calls:
for tool_call in tool_calls:
function_name = tool_call.function.name
if function_name not in functions:
self.logger.warning(f"模型请求未知函数: {function_name}")
tool_content = json.dumps(
{
"handled": False,
"messages": [f"Unknown function: {function_name}"],
"metadata": {"error": "unknown_function"},
},
ensure_ascii=False,
)
else:
try:
arguments = json.loads(tool_call.function.arguments or "{}")
except json.JSONDecodeError:
arguments = {}
spec = functions[function_name]
result = executor(spec, arguments)
tool_content = formatter(result)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_content,
}
)
continue
# 没有工具调用,认为模型给出了最终回答
final_content = message.content or ""
return LLMRunResult(handled=True, final_response=final_content)
self.logger.warning("达到最大函数调用轮数,未得到最终回答")
return LLMRunResult(handled=False, error="max_rounds")
# ---------------------------------------------------------------------
# Prompt-based fallback workflow
# ---------------------------------------------------------------------
def _run_prompt_loop(
self,
ctx: MessageContext,
chat_model: Any,
functions: Dict[str, FunctionSpec],
executor: Callable[[FunctionSpec, Dict[str, Any]], FunctionResult],
) -> LLMRunResult:
system_prompt = self._build_prompt_system_text(functions)
user_input = f"用户输入:{ctx.text}"
ai_response = chat_model.get_answer(
user_input,
wxid=ctx.get_receiver(),
system_prompt_override=system_prompt,
)
json_match = re.search(r"\{.*\}", ai_response, re.DOTALL)
if not json_match:
self.logger.warning(f"提示词模式下无法解析JSON: {ai_response}")
return LLMRunResult(handled=False)
try:
decision = json.loads(json_match.group(0))
except json.JSONDecodeError as exc:
self.logger.error(f"提示词模式 JSON 解析失败: {exc}")
return LLMRunResult(handled=False)
action_type = decision.get("action_type")
if action_type == "chat":
# 提示词模式下无法获得模型最终回答,交给上层兜底
return LLMRunResult(handled=False)
if action_type != "function":
self.logger.warning(f"未知的action_type: {action_type}")
return LLMRunResult(handled=False)
function_name = decision.get("function_name")
if function_name not in functions:
self.logger.warning(f"未知的功能名 - {function_name}")
return LLMRunResult(handled=False)
arguments = decision.get("arguments", {})
result = executor(functions[function_name], arguments)
if not result.handled:
return LLMRunResult(handled=False)
return LLMRunResult(handled=True, final_response="\n".join(result.messages))
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _convert_assistant_message(message: Any) -> Dict[str, Any]:
entry: Dict[str, Any] = {
"role": "assistant",
"content": message.content,
}
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
entry["tool_calls"] = []
for tool_call in tool_calls:
entry["tool_calls"].append(
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
},
}
)
return entry
@staticmethod
def _build_functions_for_openai(functions: Dict[str, FunctionSpec]) -> List[Dict[str, Any]]:
openai_functions = []
for spec in functions.values():
openai_functions.append(
{
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters_schema,
}
)
return openai_functions
@staticmethod
def _build_prompt_system_text(functions: Dict[str, FunctionSpec]) -> str:
prompt = """你是一个智能路由助手。根据用户输入判断是否需要调用以下函数之一。"""
for spec in functions.values():
prompt += f"\n- {spec.name}: {spec.description}"
prompt += """
请严格输出JSON{"action_type": "chat"} 或 {"action_type": "function", "function_name": "...", "arguments": {...}}
"""
return prompt
def validate_arguments(self, arguments: Dict[str, Any], schema: Dict[str, Any]) -> bool:
try:
required_fields = schema.get("required", [])
properties = schema.get("properties", {})
for field in required_fields:
if field not in arguments:
self.logger.warning(f"缺少必需参数: {field}")
return False
for field, value in arguments.items():
if field not in properties:
continue
expected_type = properties[field].get("type")
if expected_type == "string" and not isinstance(value, str):
self.logger.warning(f"参数 {field} 类型不正确,期望 string得到 {type(value)}")
return False
if expected_type == "integer" and not isinstance(value, int):
self.logger.warning(f"参数 {field} 类型不正确,期望 integer得到 {type(value)}")
return False
if expected_type == "number" and not isinstance(value, (int, float)):
self.logger.warning(f"参数 {field} 类型不正确,期望 number得到 {type(value)}")
return False
return True
except Exception as exc:
self.logger.error(f"参数验证失败: {exc}")
return False

61
function_calls/models.py Normal file
View File

@@ -0,0 +1,61 @@
"""
Function Call 参数模型定义
"""
from typing import Literal, Optional
from pydantic import BaseModel
class WeatherArgs(BaseModel):
"""天气查询参数"""
city: str
class NewsArgs(BaseModel):
"""新闻查询参数 - 无需参数"""
pass
class ReminderArgs(BaseModel):
"""设置提醒参数"""
type: Literal["once", "daily", "weekly"]
time: str
content: str
weekday: Optional[int] = None
class ReminderListArgs(BaseModel):
"""查看提醒列表参数 - 无需参数"""
pass
class ReminderDeleteArgs(BaseModel):
"""删除提醒参数"""
reminder_id: str
class PerplexityArgs(BaseModel):
"""Perplexity搜索参数"""
query: str
class HelpArgs(BaseModel):
"""帮助信息参数 - 无需参数"""
pass
class SummaryArgs(BaseModel):
"""消息总结参数 - 无需参数"""
pass
class ClearMessagesArgs(BaseModel):
"""清除消息参数 - 无需参数"""
pass
class InsultArgs(BaseModel):
"""骂人功能参数"""
target_user: str

108
function_calls/registry.py Normal file
View File

@@ -0,0 +1,108 @@
"""
函数注册中心
"""
import logging
from typing import Dict, Any, get_type_hints
from pydantic import BaseModel
from .spec import FunctionSpec
logger = logging.getLogger(__name__)
# 全局函数注册表
FUNCTION_REGISTRY: Dict[str, FunctionSpec] = {}
def register_function(spec: FunctionSpec) -> None:
"""注册函数到全局注册表"""
if spec.name in FUNCTION_REGISTRY:
raise ValueError(f"重复的函数名: {spec.name}")
FUNCTION_REGISTRY[spec.name] = spec
logger.info(f"注册函数: {spec.name} - {spec.description}")
def get_function(name: str) -> FunctionSpec:
"""获取指定名称的函数规格"""
if name not in FUNCTION_REGISTRY:
raise ValueError(f"未找到函数: {name}")
return FUNCTION_REGISTRY[name]
def list_functions() -> Dict[str, FunctionSpec]:
"""获取所有已注册的函数"""
return FUNCTION_REGISTRY.copy()
def build_schema_from_model(model_class) -> Dict[str, Any]:
"""从 Pydantic 模型构建 JSON Schema"""
if issubclass(model_class, BaseModel):
return model_class.model_json_schema()
else:
# 简单的dataclass或类型注解支持
hints = get_type_hints(model_class)
properties = {}
required = []
for field_name, field_type in hints.items():
if field_name.startswith('_'):
continue
properties[field_name] = _type_to_schema(field_type)
required.append(field_name)
return {
"type": "object",
"properties": properties,
"required": required
}
def _type_to_schema(field_type) -> Dict[str, Any]:
"""将Python类型转换为JSON Schema"""
if field_type == str:
return {"type": "string"}
elif field_type == int:
return {"type": "integer"}
elif field_type == float:
return {"type": "number"}
elif field_type == bool:
return {"type": "boolean"}
else:
return {"type": "string", "description": f"类型: {field_type}"}
def tool_function(name: str, description: str, examples: list[str] = None, **meta):
"""
装饰器自动注册函数到Function Call系统
@tool_function(
name="weather_query",
description="查询天气",
examples=["北京天气怎么样"]
)
def handle_weather(ctx: MessageContext, args: WeatherArgs) -> FunctionResult:
pass
"""
def wrapper(func):
# 获取函数参数类型注解
hints = get_type_hints(func)
args_type = hints.get('args')
if args_type:
schema = build_schema_from_model(args_type)
else:
schema = {"type": "object", "properties": {}, "required": []}
spec = FunctionSpec(
name=name,
description=description,
parameters_schema=schema,
handler=func,
examples=examples or [],
**meta
)
register_function(spec)
return func
return wrapper

227
function_calls/router.py Normal file
View File

@@ -0,0 +1,227 @@
"""Function Call 路由器"""
import logging
from typing import Any, Dict, Optional
from commands.context import MessageContext
from .spec import FunctionResult, FunctionSpec
from .registry import list_functions
from .llm import FunctionCallLLM
logger = logging.getLogger(__name__)
class FunctionCallRouter:
"""函数调用路由器"""
def __init__(self, robot_instance=None):
self.robot_instance = robot_instance
self.llm = FunctionCallLLM()
self.logger = logger
def _check_scope_and_permissions(self, ctx: MessageContext, spec: FunctionSpec) -> bool:
"""检查作用域和权限"""
# 1. 检查作用域
if spec.scope != "both":
if (spec.scope == "group" and not ctx.is_group) or \
(spec.scope == "private" and ctx.is_group):
return False
# 2. 检查是否需要@机器人(仅在群聊中有效)
if ctx.is_group and spec.require_at and not ctx.is_at_bot:
return False
# 3. 检查权限如果有auth字段
if spec.auth:
# TODO: 实现权限检查逻辑
pass
return True
def _try_direct_command_match(self, ctx: MessageContext) -> Optional[str]:
"""
尝试直接命令匹配避免不必要的LLM调用
返回匹配的函数名如果没有匹配则返回None
"""
text = ctx.text.strip().lower()
# 定义一些明确的命令关键字映射
direct_commands = {
"help": "help",
"帮助": "help",
"指令": "help",
"新闻": "news_query",
"summary": "summary",
"总结": "summary",
"clearmessages": "clear_messages",
"清除历史": "clear_messages"
}
# 检查完全匹配
if text in direct_commands:
return direct_commands[text]
# 检查以特定前缀开头的命令
if text.startswith("ask ") and len(text) > 4:
return "perplexity_search"
if text.startswith("天气") or text.startswith("天气预报"):
return "weather_query"
if text in ["查看提醒", "我的提醒", "提醒列表"]:
return "reminder_list"
if text.startswith("骂一下"):
return "insult"
return None
def dispatch(self, ctx: MessageContext) -> bool:
"""
分发消息到函数处理器
返回: 是否成功处理
"""
try:
# 确保context可以访问到robot实例
if self.robot_instance and not ctx.robot:
ctx.robot = self.robot_instance
if hasattr(self.robot_instance, 'LOG') and not ctx.logger:
ctx.logger = self.robot_instance.LOG
if ctx.logger:
ctx.logger.debug(f"FunctionCallRouter 开始处理: '{ctx.text}', 来自: {ctx.sender_name}")
# 获取所有可用函数
functions = list_functions()
if not functions:
self.logger.warning("没有注册任何函数")
return False
# 第一步:尝试直接命令匹配
direct_function = self._try_direct_command_match(ctx)
if direct_function and direct_function in functions:
spec = functions[direct_function]
if not self._check_scope_and_permissions(ctx, spec):
return False
arguments = self._extract_arguments_for_direct_command(ctx, direct_function)
if not self.llm.validate_arguments(arguments, spec.parameters_schema):
self.logger.warning(f"直接命令 {direct_function} 参数验证失败")
return False
result = self._invoke_function(ctx, spec, arguments)
if result.handled:
result.dispatch(ctx)
return True
# 如果没有处理成功继续尝试LLM流程
# 第二步使用LLM执行多轮函数调用
llm_result = self.llm.run(
ctx,
functions,
lambda spec, args: self._invoke_function(ctx, spec, args),
self._format_tool_response,
)
if not llm_result.handled:
return False
if llm_result.final_response:
at = ctx.msg.sender if ctx.is_group else ""
ctx.send_text(llm_result.final_response, at)
return True
return True
except Exception as e:
self.logger.error(f"FunctionCallRouter dispatch 异常: {e}")
return False
def _extract_arguments_for_direct_command(self, ctx: MessageContext, function_name: str) -> Dict[str, Any]:
"""为直接命令提取参数"""
text = ctx.text.strip()
if function_name == "weather_query":
# 提取城市名
if text.startswith("天气预报 "):
city = text[4:].strip()
elif text.startswith("天气 "):
city = text[3:].strip()
else:
city = ""
return {"city": city}
elif function_name == "perplexity_search":
# 提取搜索查询
if text.startswith("ask "):
query = text[4:].strip()
else:
query = text
return {"query": query}
elif function_name == "insult":
# 提取要骂的用户
import re
match = re.search(r"骂一下\s*@([^\s@]+)", text)
target_user = match.group(1) if match else ""
return {"target_user": target_user}
# 对于不需要参数的函数,返回空字典
return {}
def _invoke_function(self, ctx: MessageContext, spec: FunctionSpec, arguments: Dict[str, Any]) -> FunctionResult:
"""调用函数处理器,返回结构化结果"""
try:
if ctx.logger:
ctx.logger.info(f"执行函数: {spec.name}, 参数: {arguments}")
args_instance = self._create_args_instance(spec, arguments)
result = spec.handler(ctx, args_instance)
if not isinstance(result, FunctionResult):
raise TypeError(f"函数 {spec.name} 返回了非 FunctionResult 类型: {type(result)}")
if ctx.logger and not result.handled:
ctx.logger.warning(f"函数 {spec.name} 返回未处理状态")
return result
except Exception as exc:
self.logger.error(f"执行函数 {spec.name} 异常: {exc}")
return FunctionResult(
handled=False,
messages=[f"函数 {spec.name} 执行失败: {exc}"],
metadata={"error": str(exc)},
)
def _create_args_instance(self, spec: FunctionSpec, arguments: Dict[str, Any]):
"""根据函数规格创建参数实例"""
try:
# 获取函数的类型注解
from typing import get_type_hints
hints = get_type_hints(spec.handler)
args_type = hints.get('args')
if args_type:
# 如果是Pydantic模型
if hasattr(args_type, 'model_validate'):
return args_type.model_validate(arguments)
elif hasattr(args_type, '__init__'):
# 普通类
return args_type(**arguments)
# 如果没有类型注解,返回参数字典
return arguments
except Exception as exc:
self.logger.error(f"创建参数实例失败: {exc}")
raise
@staticmethod
def _format_tool_response(result: FunctionResult) -> str:
"""将 FunctionResult 格式化为供 LLM 读取的 tool 响应"""
return result.to_tool_content()

View File

@@ -0,0 +1,22 @@
"""Service helpers for Function Call handlers."""
from .weather import get_weather_report
from .news import get_news_digest
from .reminder import create_reminder, list_reminders, delete_reminder
from .help import build_help_text
from .group_tools import summarize_messages, clear_group_messages
from .perplexity import run_perplexity
from .insult import build_insult
__all__ = [
"get_weather_report",
"get_news_digest",
"create_reminder",
"list_reminders",
"delete_reminder",
"build_help_text",
"summarize_messages",
"clear_group_messages",
"run_perplexity",
"build_insult",
]

View File

@@ -0,0 +1,47 @@
"""Group related utilities for Function Call handlers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from commands.context import MessageContext
@dataclass
class GroupToolResult:
success: bool
message: str
def summarize_messages(ctx: MessageContext) -> GroupToolResult:
if not ctx.is_group:
return GroupToolResult(success=True, message="⚠️ 消息总结功能仅支持群聊")
if not ctx.robot or not hasattr(ctx.robot, "message_summary") or not hasattr(ctx.robot, "chat"):
return GroupToolResult(success=False, message="⚠️ 消息总结功能不可用")
try:
summary = ctx.robot.message_summary.summarize_messages(ctx.msg.roomid, ctx.robot.chat)
return GroupToolResult(success=True, message=summary)
except Exception as exc:
if ctx.logger:
ctx.logger.error(f"生成消息总结出错: {exc}")
return GroupToolResult(success=False, message="⚠️ 生成消息总结失败")
def clear_group_messages(ctx: MessageContext) -> GroupToolResult:
if not ctx.is_group:
return GroupToolResult(success=True, message="⚠️ 消息历史管理功能仅支持群聊")
if not ctx.robot or not hasattr(ctx.robot, "message_summary"):
return GroupToolResult(success=False, message="⚠️ 消息历史管理功能不可用")
try:
cleared = ctx.robot.message_summary.clear_message_history(ctx.msg.roomid)
if cleared:
return GroupToolResult(success=True, message="✅ 已清除本群的消息历史记录")
return GroupToolResult(success=True, message="⚠️ 本群没有消息历史记录")
except Exception as exc:
if ctx.logger:
ctx.logger.error(f"清除消息历史出错: {exc}")
return GroupToolResult(success=False, message="⚠️ 清除消息历史失败")

View File

@@ -0,0 +1,34 @@
"""Static help text utility."""
from __future__ import annotations
HELP_LINES = [
"🤖 泡泡的指令列表 🤖",
"",
"【实用工具】",
"- 天气/温度 [城市名]",
"- 天气预报/预报 [城市名]",
"- 新闻",
"- ask [问题]",
"",
"【决斗 & 偷袭】",
"- 决斗@XX",
"- 偷袭@XX",
"- 决斗排行/排行榜",
"- 我的战绩/决斗战绩",
"- 我的装备/查看装备",
"- 改名 [旧名] [新名]",
"",
"【提醒】",
"- 提醒xxxxx一次性、每日、每周",
"- 查看提醒/我的提醒/提醒列表",
"- 删..提醒..",
"",
"【群聊工具】",
"- summary/总结",
"- clearmessages/清除历史",
]
def build_help_text() -> str:
"""Return formatted help text."""
return "\n".join(HELP_LINES)

View File

@@ -0,0 +1,50 @@
"""Group insult helper utilities."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from commands.context import MessageContext
from function.func_insult import generate_random_insult
@dataclass
class InsultResult:
success: bool
message: str
def build_insult(ctx: MessageContext, target_name: str) -> InsultResult:
if not ctx.is_group:
return InsultResult(success=True, message="❌ 骂人功能只支持群聊哦~")
cleaned = target_name.strip()
if not cleaned:
return InsultResult(success=False, message="❌ 需要提供要骂的对象")
actual_target = cleaned
target_wxid: Optional[str] = None
try:
members = ctx.room_members
if members:
for wxid, name in members.items():
if cleaned == name:
target_wxid = wxid
actual_target = name
break
if target_wxid is None:
for wxid, name in members.items():
if cleaned in name and wxid != ctx.robot_wxid:
target_wxid = wxid
actual_target = name
break
except Exception as exc:
if ctx.logger:
ctx.logger.error(f"查找群成员信息时出错: {exc}")
if target_wxid and target_wxid == ctx.robot_wxid:
return InsultResult(success=True, message="😅 不行,我不能骂我自己。")
insult_text = generate_random_insult(actual_target)
return InsultResult(success=True, message=insult_text)

View File

@@ -0,0 +1,36 @@
"""News related service helpers for Function Call handlers."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from function.func_news import News
logger = logging.getLogger(__name__)
@dataclass
class NewsResult:
success: bool
message: str
is_today: Optional[bool] = None
def get_news_digest() -> NewsResult:
"""Fetch latest news digest."""
try:
news_instance = News()
is_today, content = news_instance.get_important_news()
if is_today:
message = f"📰 今日要闻来啦:\n{content}"
else:
if content:
message = f" 今日新闻暂未发布,为您找到最近的一条新闻:\n{content}"
else:
message = "❌ 获取新闻失败,请稍后重试"
return NewsResult(success=True, message=message, is_today=is_today)
except Exception as exc:
logger.error(f"获取新闻失败: {exc}")
return NewsResult(success=False, message="❌ 获取新闻时发生错误")

View File

@@ -0,0 +1,62 @@
"""Perplexity integration helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from commands.context import MessageContext
@dataclass
class PerplexityResult:
success: bool
messages: List[str]
handled_externally: bool = False
def run_perplexity(ctx: MessageContext, query: str) -> PerplexityResult:
query = query.strip()
if not query:
at = ctx.msg.sender if ctx.is_group else ""
return PerplexityResult(success=True, messages=["请告诉我你想搜索什么内容"], handled_externally=False)
perplexity_instance = getattr(ctx.robot, 'perplexity', None)
if not perplexity_instance:
return PerplexityResult(success=True, messages=["❌ Perplexity搜索功能当前不可用"], handled_externally=False)
content_for_perplexity = f"ask {query}"
chat_id = ctx.get_receiver()
sender_wxid = ctx.msg.sender
room_id = ctx.msg.roomid if ctx.is_group else None
was_handled, fallback_prompt = perplexity_instance.process_message(
content=content_for_perplexity,
chat_id=chat_id,
sender=sender_wxid,
roomid=room_id,
from_group=ctx.is_group,
send_text_func=ctx.send_text
)
if was_handled:
return PerplexityResult(success=True, messages=[], handled_externally=True)
if fallback_prompt:
chat_model = getattr(ctx, 'chat', None) or (getattr(ctx.robot, 'chat', None) if ctx.robot else None)
if chat_model:
try:
import time
current_time = time.strftime("%H:%M", time.localtime())
formatted_question = f"[{current_time}] {ctx.sender_name}: {query}"
answer = chat_model.get_answer(
question=formatted_question,
wxid=ctx.get_receiver(),
system_prompt_override=fallback_prompt
)
if answer:
return PerplexityResult(success=True, messages=[answer], handled_externally=False)
except Exception as exc:
if ctx.logger:
ctx.logger.error(f"默认AI处理失败: {exc}")
return PerplexityResult(success=True, messages=["❌ Perplexity搜索时发生错误"], handled_externally=False)

View File

@@ -0,0 +1,101 @@
"""Reminder related services for Function Call handlers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from function.func_reminder import ReminderManager
@dataclass
class ReminderServiceResult:
success: bool
messages: List[str]
_WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
_TYPE_LABELS = {"once": "一次性", "daily": "每日", "weekly": "每周"}
def _format_schedule(data: Dict[str, Any]) -> str:
reminder_type = data.get("type", "once")
time_str = data.get("time", "?")
if reminder_type == "once":
return f"{time_str} (一次性)"
if reminder_type == "daily":
return f"每天 {time_str}"
if reminder_type == "weekly":
weekday = data.get("weekday")
if isinstance(weekday, int) and 0 <= weekday < len(_WEEKDAY_LABELS):
return f"每周{_WEEKDAY_LABELS[weekday]} {time_str}"
return f"每周 {time_str}"
return f"{time_str}"
def create_reminder(
manager: ReminderManager,
sender_wxid: str,
data: Dict[str, Any],
roomid: Optional[str]
) -> ReminderServiceResult:
payload = {
"type": data["type"],
"time": data["time"],
"content": data["content"],
}
if data.get("weekday") is not None:
payload["weekday"] = data["weekday"]
success, info = manager.add_reminder(sender_wxid, payload, roomid=roomid)
if not success:
return ReminderServiceResult(success=False, messages=[f"❌ 设置提醒失败:{info}"])
schedule = payload.copy()
message = (
"✅ 已为您设置{type_label}提醒\n"
"时间: {schedule}\n"
"内容: {content}"
).format(
type_label=_TYPE_LABELS.get(payload["type"], ""),
schedule=_format_schedule(payload),
content=payload["content"],
)
return ReminderServiceResult(success=True, messages=[message])
def list_reminders(
manager: ReminderManager,
sender_wxid: str,
contacts: Dict[str, str]
) -> ReminderServiceResult:
reminders = manager.list_reminders(sender_wxid)
if not reminders:
return ReminderServiceResult(success=True, messages=["您还没有设置任何提醒。"])
lines: List[str] = ["📝 您设置的提醒列表(包括私聊和群聊):"]
for idx, reminder in enumerate(reminders, start=1):
schedule_display = _format_schedule({
"type": reminder.get("type"),
"time": reminder.get("time_str"),
"weekday": reminder.get("weekday"),
})
if reminder.get("type") == "once":
schedule_display = reminder.get("time_str", "?")
scope = "[私聊]"
roomid = reminder.get("roomid")
if roomid:
room_name = contacts.get(roomid) or roomid[:8]
scope = f"[群:{room_name}]"
lines.append(
f"{idx}. [ID: {reminder.get('id', '')[:6]}] {scope}{schedule_display}: {reminder.get('content', '')}"
)
return ReminderServiceResult(success=True, messages=["\n".join(lines)])
def delete_reminder(manager: ReminderManager, sender_wxid: str, reminder_id: str) -> ReminderServiceResult:
success, info = manager.delete_reminder(sender_wxid, reminder_id)
if success:
return ReminderServiceResult(success=True, messages=[f"{info}"])
return ReminderServiceResult(success=False, messages=[f"{info}"])

View File

@@ -0,0 +1,65 @@
"""Weather related service helpers for Function Call handlers."""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from typing import Optional
from function.func_weather import Weather
logger = logging.getLogger(__name__)
@dataclass
class WeatherResult:
success: bool
message: str
city: Optional[str] = None
def _load_city_codes() -> dict[str, str]:
"""Load mapping between city names and weather codes."""
city_code_path = os.path.join(os.path.dirname(__file__), '..', '..', 'function', 'main_city.json')
with open(city_code_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_weather_report(city_name: str) -> WeatherResult:
"""Return a weather report for a given city.
Args:
city_name: City provided by the user.
Returns:
WeatherResult containing success status and message.
"""
city = city_name.strip()
if not city:
return WeatherResult(success=False, message="🤔 请告诉我你想查询哪个城市的天气")
try:
city_codes = _load_city_codes()
except Exception as exc: # pragma: no cover - IO failure is rare
logger.error(f"加载城市代码失败: {exc}")
return WeatherResult(success=False, message="⚠️ 抱歉,天气功能暂时不可用")
code = city_codes.get(city)
if not code:
for name, value in city_codes.items():
if city in name:
code = value
city = name
break
if not code:
return WeatherResult(success=False, message=f"😕 找不到城市 '{city_name}' 的天气信息")
try:
weather_text = Weather(code).get_weather(include_forecast=True)
return WeatherResult(success=True, message=weather_text, city=city)
except Exception as exc: # pragma: no cover - upstream API failure
logger.error(f"获取天气数据失败: {exc}")
return WeatherResult(success=False, message=f"😥 获取 {city} 天气时遇到问题")

50
function_calls/spec.py Normal file
View File

@@ -0,0 +1,50 @@
"""函数规格定义与相关数据结构"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
from commands.context import MessageContext
@dataclass
class FunctionResult:
"""Standardized execution result returned by handlers."""
handled: bool
messages: list[str] = field(default_factory=list)
at: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
def dispatch(self, ctx: MessageContext) -> None:
"""Send messages through the context when handled successfully."""
if not self.handled:
return
for message in self.messages:
ctx.send_text(message, self.at)
def to_tool_content(self) -> str:
"""Serialize result for LLM tool messages."""
payload = {
"handled": self.handled,
"messages": self.messages,
"metadata": self.metadata or {},
}
return json.dumps(payload, ensure_ascii=False)
@dataclass
class FunctionSpec:
"""函数规格定义"""
name: str
description: str
parameters_schema: Dict[str, Any]
handler: Callable[[MessageContext, Any], FunctionResult]
examples: list[str] = field(default_factory=list)
scope: str = "both" # group / private / both
require_at: bool = False
auth: Optional[str] = None # 权限标签(可选)