Files
WechatHookBot/plugins/TravelPlanner/main.py
2026-01-08 18:46:14 +08:00

806 lines
35 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.
"""
旅行规划插件
基于高德地图 API提供以下功能
- 地点搜索与地理编码
- 天气查询(实况 + 4天预报
- 景点/酒店/餐厅搜索
- 路径规划(驾车/公交/步行)
- 周边搜索
支持 LLM 函数调用,可与 AIChat 插件配合使用。
"""
import asyncio
import tomllib
from pathlib import Path
from typing import Any, Dict, List
from loguru import logger
from utils.plugin_base import PluginBase
from .amap_client import AmapClient, AmapConfig
class TravelPlanner(PluginBase):
"""旅行规划插件"""
description = "旅行规划助手,支持天气查询、景点搜索、路线规划"
author = "ShiHao"
version = "1.0.0"
def __init__(self):
super().__init__()
self.config = None
self.amap: AmapClient = None
async def async_init(self):
"""插件异步初始化"""
# 读取配置
config_path = Path(__file__).parent / "config.toml"
with open(config_path, "rb") as f:
self.config = tomllib.load(f)
# 初始化高德 API 客户端
amap_config = self.config.get("amap", {})
api_key = amap_config.get("api_key", "")
secret = amap_config.get("secret", "")
if not api_key:
logger.warning("TravelPlanner: 未配置高德 API Key请在 config.toml 中设置")
else:
self.amap = AmapClient(AmapConfig(
api_key=api_key,
secret=secret,
timeout=amap_config.get("timeout", 30)
))
if secret:
logger.success(f"TravelPlanner 插件已加载API Key: {api_key[:8]}...(已启用数字签名)")
else:
logger.success(f"TravelPlanner 插件已加载API Key: {api_key[:8]}...(未配置安全密钥)")
async def on_disable(self):
"""插件禁用时关闭连接"""
await super().on_disable()
if self.amap:
await self.amap.close()
logger.info("TravelPlanner: 已关闭高德 API 连接")
# ==================== LLM 工具定义 ====================
def get_llm_tools(self) -> List[Dict]:
"""返回 LLM 可调用的工具列表"""
return [
{
"type": "function",
"function": {
"name": "search_location",
"description": "【旅行工具】将地名转换为坐标和行政区划信息。仅当用户明确询问某个地点的位置信息时使用。",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "地址或地名,如:北京市、西湖、故宫"
},
"city": {
"type": "string",
"description": "所在城市,可选。填写可提高搜索精度"
}
},
"required": ["address"]
}
}
},
{
"type": "function",
"function": {
"name": "query_weather",
"description": "【旅行工具】查询城市天气预报。仅当用户明确询问某城市的天气情况时使用,如'北京天气怎么样''杭州明天会下雨吗'",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如:北京、杭州、上海"
},
"forecast": {
"type": "boolean",
"description": "是否查询预报天气。true=未来4天预报false=当前实况"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_poi",
"description": "【旅行工具】搜索地点(景点、酒店、餐厅等)。仅当用户明确要求查找某城市的景点、酒店、餐厅等时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "搜索城市,如:杭州、北京"
},
"keyword": {
"type": "string",
"description": "搜索关键词,如:西湖、希尔顿酒店、火锅"
},
"category": {
"type": "string",
"enum": ["景点", "酒店", "餐厅", "购物", "交通"],
"description": "POI 类别。不填则搜索所有类别"
},
"limit": {
"type": "integer",
"description": "返回结果数量默认10最大20"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_nearby",
"description": "【旅行工具】搜索某地点周边的设施。仅当用户明确要求查找某地点附近的餐厅、酒店等时使用,如'西湖附近有什么好吃的'",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "中心地点名称,如:西湖、故宫"
},
"city": {
"type": "string",
"description": "所在城市"
},
"keyword": {
"type": "string",
"description": "搜索关键词"
},
"category": {
"type": "string",
"enum": ["景点", "酒店", "餐厅", "购物", "交通"],
"description": "POI 类别"
},
"radius": {
"type": "integer",
"description": "搜索半径默认3000最大50000"
}
},
"required": ["location", "city"]
}
}
},
{
"type": "function",
"function": {
"name": "plan_route",
"description": "【旅行工具】规划两地之间的出行路线。仅当用户明确要求规划从A到B的路线时使用'从北京到杭州怎么走''上海到苏州的高铁'",
"parameters": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"description": "起点地名,如:北京、上海虹桥站"
},
"destination": {
"type": "string",
"description": "终点地名,如:杭州、西湖"
},
"origin_city": {
"type": "string",
"description": "起点所在城市"
},
"destination_city": {
"type": "string",
"description": "终点所在城市(跨城时必填)"
},
"mode": {
"type": "string",
"enum": ["driving", "transit", "walking"],
"description": "出行方式driving=驾车transit=公交/高铁walking=步行。默认 transit"
}
},
"required": ["origin", "destination", "origin_city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_travel_info",
"description": "【旅行工具】获取目的地城市的旅行信息(天气、景点、交通)。仅当用户明确表示要去某城市旅游并询问相关信息时使用,如'我想去杭州玩,帮我看看''北京旅游攻略'",
"parameters": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "目的地城市,如:杭州、成都"
},
"origin": {
"type": "string",
"description": "出发城市,如:北京、上海。填写后会规划交通路线"
}
},
"required": ["destination"]
}
}
},
{
"type": "function",
"function": {
"name": "plan_detailed_trip",
"description": "【必须调用】详细行程规划工具。当用户提到'规划行程''安排旅行''去XX旅游''帮我规划''我想去XX玩'必须调用此工具获取实时的交通、酒店、景点信息。此工具会返回1.从用户家到火车站的详细路线地铁几号线、哪站上哪站下2.高铁车次和时刻 3.酒店推荐 4.景点推荐 5.餐厅推荐 6.天气预报。",
"parameters": {
"type": "object",
"properties": {
"origin_city": {
"type": "string",
"description": "出发城市,如:合肥、上海、北京"
},
"origin_address": {
"type": "string",
"description": "用户的具体出发地址合肥市蜀山区xxx小区、上海市浦东新区xxx路。如果用户没提供具体地址填写城市名即可"
},
"destination": {
"type": "string",
"description": "目的地城市,如:北京、杭州、成都"
},
"days": {
"type": "integer",
"description": "旅行天数默认2天"
},
"departure_time": {
"type": "string",
"description": "出发时间偏好,如:周六早上、明天下午"
},
"preferences": {
"type": "string",
"description": "旅行偏好,如:喜欢历史文化、想吃美食、带小孩"
}
},
"required": ["origin_city", "destination"]
}
}
}
]
async def execute_llm_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
bot,
from_wxid: str
) -> Dict[str, Any]:
"""执行 LLM 工具调用"""
if not self.amap:
return {"success": False, "message": "高德 API 未配置,请联系管理员设置 API Key"}
try:
if tool_name == "search_location":
return await self._tool_search_location(arguments)
elif tool_name == "query_weather":
return await self._tool_query_weather(arguments)
elif tool_name == "search_poi":
return await self._tool_search_poi(arguments)
elif tool_name == "search_nearby":
return await self._tool_search_nearby(arguments)
elif tool_name == "plan_route":
return await self._tool_plan_route(arguments)
elif tool_name == "get_travel_info":
return await self._tool_get_travel_info(arguments)
elif tool_name == "plan_detailed_trip":
return await self._tool_plan_detailed_trip(arguments)
else:
return {"success": False, "message": f"未知工具: {tool_name}"}
except Exception as e:
logger.error(f"TravelPlanner 工具执行失败: {tool_name}, 错误: {e}")
return {"success": False, "message": f"工具执行失败: {str(e)}"}
# ==================== 工具实现 ====================
async def _tool_search_location(self, args: Dict) -> Dict:
"""地点搜索工具"""
address = args.get("address", "")
city = args.get("city")
result = await self.amap.geocode(address, city)
if not result["success"]:
return {"success": False, "message": result.get("error", "地点搜索失败")}
return {
"success": True,
"message": f"已找到地点:{result['formatted_address']}",
"data": {
"name": address,
"formatted_address": result["formatted_address"],
"location": result["location"],
"province": result["province"],
"city": result["city"],
"district": result["district"],
"adcode": result["adcode"]
}
}
async def _tool_query_weather(self, args: Dict) -> Dict:
"""天气查询工具"""
city = args.get("city", "")
forecast = args.get("forecast", True)
extensions = "all" if forecast else "base"
result = await self.amap.get_weather(city, extensions)
if not result["success"]:
return {"success": False, "message": result.get("error", "天气查询失败")}
if result["type"] == "live":
return {
"success": True,
"message": f"{result['city']}当前天气:{result['weather']}{result['temperature']}",
"data": {
"city": result["city"],
"weather": result["weather"],
"temperature": result["temperature"],
"humidity": result["humidity"],
"wind": f"{result['winddirection']}{result['windpower']}",
"reporttime": result["reporttime"]
}
}
else:
forecasts = result["forecasts"]
weather_text = "\n".join([
f"- {f['date']} 星期{self._weekday_cn(f['week'])}:白天{f['dayweather']} {f['daytemp']}℃,夜间{f['nightweather']} {f['nighttemp']}"
for f in forecasts
])
return {
"success": True,
"message": f"{result['city']}未来天气预报:\n{weather_text}",
"data": {
"city": result["city"],
"province": result["province"],
"forecasts": forecasts,
"reporttime": result["reporttime"]
}
}
async def _tool_search_poi(self, args: Dict) -> Dict:
"""POI 搜索工具"""
city = args.get("city", "")
keyword = args.get("keyword")
category = args.get("category")
limit = min(args.get("limit", 10), 20)
# 获取 POI 类型代码
types = None
if category:
poi_types = self.config.get("poi_types", {})
types = poi_types.get(category)
result = await self.amap.search_poi(
keywords=keyword,
types=types,
city=city,
citylimit=True,
offset=limit
)
if not result["success"]:
return {"success": False, "message": result.get("error", "搜索失败")}
pois = result["pois"]
if not pois:
return {"success": False, "message": f"{city}未找到相关地点"}
# 格式化输出
poi_list = []
for i, poi in enumerate(pois, 1):
info = f"{i}. {poi['name']}"
if poi.get("address"):
info += f" - {poi['address']}"
if poi.get("rating"):
info += f"{poi['rating']}"
if poi.get("cost"):
info += f" 人均¥{poi['cost']}"
poi_list.append(info)
return {
"success": True,
"message": f"{city}找到{len(pois)}个结果:\n" + "\n".join(poi_list),
"data": {
"city": city,
"category": category or "全部",
"count": len(pois),
"pois": pois
}
}
async def _tool_search_nearby(self, args: Dict) -> Dict:
"""周边搜索工具"""
location_name = args.get("location", "")
city = args.get("city", "")
keyword = args.get("keyword")
category = args.get("category")
radius = min(args.get("radius", 3000), 50000)
# 先获取中心点坐标
geo_result = await self.amap.geocode(location_name, city)
if not geo_result["success"]:
return {"success": False, "message": f"无法定位 {location_name}"}
location = geo_result["location"]
# 获取 POI 类型代码
types = None
if category:
poi_types = self.config.get("poi_types", {})
types = poi_types.get(category)
result = await self.amap.search_around(
location=location,
keywords=keyword,
types=types,
radius=radius,
offset=10
)
if not result["success"]:
return {"success": False, "message": result.get("error", "周边搜索失败")}
pois = result["pois"]
if not pois:
return {"success": False, "message": f"{location_name}周边未找到相关地点"}
# 格式化输出
poi_list = []
for i, poi in enumerate(pois, 1):
info = f"{i}. {poi['name']}"
if poi.get("distance"):
info += f" ({poi['distance']}米)"
if poi.get("rating"):
info += f"{poi['rating']}"
poi_list.append(info)
return {
"success": True,
"message": f"{location_name}周边{radius}米内找到{len(pois)}个结果:\n" + "\n".join(poi_list),
"data": {
"center": location_name,
"radius": radius,
"category": category or "全部",
"count": len(pois),
"pois": pois
}
}
async def _tool_plan_route(self, args: Dict) -> Dict:
"""路线规划工具"""
origin = args.get("origin", "")
destination = args.get("destination", "")
origin_city = args.get("origin_city", "")
destination_city = args.get("destination_city", origin_city)
mode = args.get("mode", "transit")
# 获取起终点坐标
origin_geo = await self.amap.geocode(origin, origin_city)
if not origin_geo["success"]:
return {"success": False, "message": f"无法定位起点:{origin}"}
dest_geo = await self.amap.geocode(destination, destination_city)
if not dest_geo["success"]:
return {"success": False, "message": f"无法定位终点:{destination}"}
origin_loc = origin_geo["location"]
dest_loc = dest_geo["location"]
# 根据模式规划路线
if mode == "driving":
result = await self.amap.route_driving(origin_loc, dest_loc)
if not result["success"]:
return {"success": False, "message": result.get("error", "驾车路线规划失败")}
distance_km = result["distance"] / 1000
duration_h = result["duration"] / 3600
msg = f"🚗 驾车路线:{origin}{destination}\n"
msg += f"距离:{distance_km:.1f}公里,预计{self._format_duration(result['duration'])}\n"
if result["tolls"]:
msg += f"收费:约{result['tolls']}\n"
if result["taxi_cost"]:
msg += f"打车费用:约{result['taxi_cost']}"
return {
"success": True,
"message": msg,
"data": result
}
elif mode == "transit":
result = await self.amap.route_transit(
origin_loc, dest_loc,
city=origin_city,
cityd=destination_city if destination_city != origin_city else None
)
if not result["success"]:
return {"success": False, "message": result.get("error", "公交路线规划失败")}
msg = f"🚄 公交/高铁路线:{origin}{destination}\n"
for i, transit in enumerate(result["transits"][:2], 1):
msg += f"\n方案{i}{self._format_duration(transit['duration'])}"
if transit.get("cost"):
msg += f",约{transit['cost']}"
msg += "\n"
for seg in transit["segments"]:
if seg["type"] == "walking" and seg["distance"] > 100:
msg += f" 🚶 步行{seg['distance']}\n"
elif seg["type"] == "bus":
msg += f" 🚌 {seg['name']}{seg['departure_stop']}{seg['arrival_stop']}{seg['via_num']}站)\n"
elif seg["type"] == "railway":
msg += f" 🚄 {seg['trip']} {seg['name']}{seg['departure_stop']} {seg.get('departure_time', '')}{seg['arrival_stop']} {seg.get('arrival_time', '')}\n"
return {
"success": True,
"message": msg.strip(),
"data": result
}
elif mode == "walking":
result = await self.amap.route_walking(origin_loc, dest_loc)
if not result["success"]:
return {"success": False, "message": result.get("error", "步行路线规划失败")}
return {
"success": True,
"message": f"🚶 步行路线:{origin}{destination}\n距离:{result['distance']}米,预计{self._format_duration(result['duration'])}",
"data": result
}
return {"success": False, "message": f"不支持的出行方式:{mode}"}
async def _tool_get_travel_info(self, args: Dict) -> Dict:
"""一键获取旅行信息"""
destination = args.get("destination", "")
origin = args.get("origin")
info = {"destination": destination}
msg_parts = [f"📍 {destination} 旅行信息\n"]
# 1. 查询天气
weather_result = await self.amap.get_weather(destination, "all")
if weather_result["success"]:
info["weather"] = weather_result
msg_parts.append("🌤️ 天气预报:")
for f in weather_result["forecasts"][:3]:
msg_parts.append(f" {f['date']} {f['dayweather']} {f['nighttemp']}~{f['daytemp']}")
# 2. 搜索热门景点
poi_result = await self.amap.search_poi(
types="110000", # 景点
city=destination,
citylimit=True,
offset=5
)
if poi_result["success"] and poi_result["pois"]:
info["attractions"] = poi_result["pois"]
msg_parts.append("\n🏞️ 热门景点:")
for poi in poi_result["pois"][:5]:
rating = f"{poi['rating']}" if poi.get("rating") else ""
msg_parts.append(f"{poi['name']}{rating}")
# 3. 规划交通路线(如果提供了出发地)
if origin:
origin_geo = await self.amap.geocode(origin)
dest_geo = await self.amap.geocode(destination)
if origin_geo["success"] and dest_geo["success"]:
route_result = await self.amap.route_transit(
origin_geo["location"],
dest_geo["location"],
city=origin_geo.get("city", origin),
cityd=dest_geo.get("city", destination)
)
if route_result["success"] and route_result["transits"]:
info["route"] = route_result
transit = route_result["transits"][0]
msg_parts.append(f"\n🚄 从{origin}出发:")
msg_parts.append(f" 预计{self._format_duration(transit['duration'])}")
# 显示主要交通工具
for seg in transit["segments"]:
if seg["type"] == "railway":
msg_parts.append(f" {seg['trip']}{seg['departure_stop']}{seg['arrival_stop']}")
break
return {
"success": True,
"message": "\n".join(msg_parts),
"data": info,
"need_ai_reply": True # 让 AI 根据这些信息生成详细的行程规划
}
async def _tool_plan_detailed_trip(self, args: Dict) -> Dict:
"""
详细行程规划工具(优化版:并行 API 调用)
"""
origin_city = args.get("origin_city", "")
origin_address = args.get("origin_address", "") or origin_city
destination = args.get("destination", "")
days = args.get("days", 2)
departure_time = args.get("departure_time", "")
preferences = args.get("preferences", "")
info = {
"origin_city": origin_city,
"origin_address": origin_address,
"destination": destination,
"days": days
}
# ========== 第1步并行获取基础信息 ==========
user_geo_task = self.amap.geocode(origin_address, origin_city)
dest_geo_task = self.amap.geocode(destination)
weather_task = self.amap.get_weather(destination, "all")
user_geo, dest_geo, weather_result = await asyncio.gather(
user_geo_task, dest_geo_task, weather_task,
return_exceptions=True
)
# 处理地理编码结果
if isinstance(user_geo, Exception) or not user_geo.get("success"):
user_geo = await self.amap.geocode(origin_city)
if isinstance(dest_geo, Exception) or not dest_geo.get("success"):
return {"success": False, "message": f"无法定位目的地:{destination}"}
if not user_geo.get("success"):
return {"success": False, "message": f"无法定位出发地:{origin_address}"}
user_loc = user_geo["location"]
dest_loc = dest_geo["location"]
origin_city_name = user_geo.get("city") or origin_city
dest_city_name = dest_geo.get("city") or destination
# ========== 第2步并行搜索火车站和目的地信息 ==========
origin_stations_task = self.amap.search_poi(types="150200", city=origin_city, citylimit=True, offset=3)
dest_stations_task = self.amap.search_poi(types="150200", city=destination, citylimit=True, offset=2)
hotels_task = self.amap.search_poi(types="100100|100101", city=destination, citylimit=True, offset=5)
attractions_task = self.amap.search_poi(types="110000", city=destination, citylimit=True, offset=6)
food_task = self.amap.search_poi(types="050000", city=destination, citylimit=True, offset=5)
results = await asyncio.gather(
origin_stations_task, dest_stations_task, hotels_task, attractions_task, food_task,
return_exceptions=True
)
origin_stations, dest_stations, hotels, attractions, food = results
# ========== 第3步规划到火车站的路线只规划1个最近的 ==========
best_station = None
best_route = None
if not isinstance(origin_stations, Exception) and origin_stations.get("success") and origin_stations.get("pois"):
station = origin_stations["pois"][0] # 只取第一个火车站
try:
route = await self.amap.route_transit(user_loc, station["location"], city=origin_city_name)
if route.get("success") and route.get("transits"):
best_station = station
best_route = route["transits"][0]
except Exception as e:
logger.warning(f"规划到火车站路线失败: {e}")
# ========== 第4步规划城际交通 ==========
transit_info = None
if best_station and not isinstance(dest_stations, Exception) and dest_stations.get("success") and dest_stations.get("pois"):
try:
dest_station = dest_stations["pois"][0]
transit = await self.amap.route_transit(
best_station["location"], dest_station["location"],
city=origin_city_name, cityd=dest_city_name
)
if transit.get("success") and transit.get("transits"):
transit_info = transit["transits"][0]
except Exception as e:
logger.warning(f"城际交通规划失败: {e}")
# ========== 组装输出 ==========
sections = []
sections.append(f"📋 {origin_address}{destination} {days}天行程\n")
sections.append(f"📍 出发地:{user_geo.get('formatted_address', origin_address)}\n")
# 天气
if not isinstance(weather_result, Exception) and weather_result.get("success"):
sections.append("【天气预报】")
for f in weather_result.get("forecasts", [])[:3]:
sections.append(f" {f['date']}{f['dayweather']} {f['nighttemp']}~{f['daytemp']}")
sections.append("")
# 到火车站
if best_station and best_route:
sections.append("【从您家到火车站】")
sections.append(f" 🚉 {best_station['name']}")
sections.append(f" ⏱️ 预计:{self._format_duration(best_route['duration'])}")
for seg in best_route.get("segments", []):
if seg["type"] == "bus":
line = seg["name"]
icon = "🚇" if "地铁" in line or "号线" in line else "🚌"
sections.append(f" {icon} {line}{seg['departure_stop']}{seg['arrival_stop']}{seg['via_num']}站)")
sections.append("")
# 城际交通
if transit_info:
sections.append("【城际高铁/火车】")
sections.append(f" ⏱️ 全程约{self._format_duration(transit_info['duration'])},费用约{transit_info.get('cost', '未知')}")
for seg in transit_info.get("segments", []):
if seg["type"] == "railway":
sections.append(f" 🚄 {seg['trip']} {seg['name']}")
sections.append(f" {seg['departure_stop']}{seg['arrival_stop']}")
sections.append("")
# 酒店
if not isinstance(hotels, Exception) and hotels.get("success") and hotels.get("pois"):
sections.append("【酒店推荐】")
for i, h in enumerate(hotels["pois"][:4], 1):
rating = f"{h['rating']}" if h.get("rating") else ""
cost = f"¥{h['cost']}/晚" if h.get("cost") else ""
sections.append(f" {i}. {h['name']} {rating} {cost}")
sections.append("")
# 景点
if not isinstance(attractions, Exception) and attractions.get("success") and attractions.get("pois"):
sections.append("【热门景点】")
for i, p in enumerate(attractions["pois"][:5], 1):
rating = f"{p['rating']}" if p.get("rating") else ""
sections.append(f" {i}. {p['name']} {rating}")
sections.append("")
# 美食
if not isinstance(food, Exception) and food.get("success") and food.get("pois"):
sections.append("【美食推荐】")
for i, p in enumerate(food["pois"][:4], 1):
cost = f"人均¥{p['cost']}" if p.get("cost") else ""
sections.append(f" {i}. {p['name']} {cost}")
sections.append("")
# 提示
sections.append(f"📌 请根据以上信息为用户安排{days}天行程")
if departure_time:
sections.append(f" 出发时间偏好:{departure_time}")
if preferences:
sections.append(f" 用户偏好:{preferences}")
return {
"success": True,
"message": "\n".join(sections),
"data": info,
"need_ai_reply": True
}
# ==================== 辅助方法 ====================
def _weekday_cn(self, week: str) -> str:
"""星期数字转中文"""
mapping = {"1": "", "2": "", "3": "", "4": "", "5": "", "6": "", "7": ""}
return mapping.get(str(week), week)
def _format_duration(self, seconds: int) -> str:
"""格式化时长"""
if seconds < 60:
return f"{seconds}"
elif seconds < 3600:
return f"{seconds // 60}分钟"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if minutes:
return f"{hours}小时{minutes}分钟"
return f"{hours}小时"