From 8a066163fe5b328aa863fc0f4b9294a450eabb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A3=E5=8D=97=E7=A7=91=E6=8A=80?= Date: Mon, 17 Mar 2025 14:20:40 +0800 Subject: [PATCH] =?UTF-8?q?update:=E5=92=8C=E9=A3=8E=E5=A4=A9=E6=B0=94?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update get_weather.py (#381) 更新天气插件,增加:通过当前IP获取用户实时位置的功能 * 重构天气查询插件 (#382) * feat: 添加获取ip信息工具函数 * refactor: 重构天气查询插件 使用和风天气作为数据源 功能更完善 --------- Co-authored-by: 欣南科技 * update:天气查询优化 --------- Co-authored-by: xiaowu911 <51016996+xiaowu911@users.noreply.github.com> Co-authored-by: Jad Co-authored-by: hrz <1710360675@qq.com> --- main/README.md | 2 +- main/xiaozhi-server/config.yaml | 8 ++ main/xiaozhi-server/core/connection.py | 22 ++-- main/xiaozhi-server/core/utils/util.py | 60 +++++++++- .../plugins_func/functions/get_weather.py | 110 ++++++++++++++---- 5 files changed, 169 insertions(+), 33 deletions(-) diff --git a/main/README.md b/main/README.md index 6f330023..df4e57a4 100644 --- a/main/README.md +++ b/main/README.md @@ -18,6 +18,6 @@ xiaozhi-esp32-server # manager-web 、manager-api接口协议 -[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU) +[manager前后端接口协议](https://app.apifox.com/invite/project?token=eXg2_tUv85q-gc3ZRowmn) [前端页面设计图](https://codesign.qq.com/app/s/526108506410828) diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 7fa8a24a..21f37c5b 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -102,6 +102,14 @@ Intent: - change_role - get_weather +# 插件的基础配置 +plugins: + # 获取天气插件的配置,这里填写你的api_key + # 这个密钥是项目共用的key,用多了可能会被限制 + # 想稳定一点就自行申请替换,每天有1000次免费调用 + # 申请地址:https://console.qweather.com/#/apps/create-key/over + get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" } + Memory: mem0ai: type: mem0ai diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index a87bccb6..adb52c1f 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -5,13 +5,15 @@ import time import queue import asyncio import traceback -from config.logger import setup_logging + import threading import websockets from typing import Dict, Any +import plugins_func.loadplugins +from config.logger import setup_logging from core.utils.dialogue import Message, Dialogue from core.handle.textHandle import handleTextMessage -from core.utils.util import get_string_no_punctuation_or_emoji, extract_json_from_string +from core.utils.util import get_string_no_punctuation_or_emoji, extract_json_from_string, get_ip_info from concurrent.futures import ThreadPoolExecutor, TimeoutError from core.handle.sendAudioHandle import sendAudioMessage from core.handle.receiveAudioHandle import handleAudioMessage @@ -20,7 +22,6 @@ from plugins_func.register import Action from config.private_config import PrivateConfig from core.auth import AuthMiddleware, AuthenticationError from core.utils.auth_code_gen import AuthCodeGenerator -import plugins_func.loadplugins TAG = __name__ @@ -37,6 +38,8 @@ class ConnectionHandler: self.websocket = None self.headers = None + self.client_ip = None + self.client_ip_info = {} self.session_id = None self.prompt = None self.welcome_msg = None @@ -95,16 +98,14 @@ class ConnectionHandler: self.use_function_call_mode = False if self.config["selected_module"]["Intent"] == 'function_call': self.use_function_call_mode = True - - self.func_handler = FunctionHandler(self.config) async def handle_connection(self, ws): try: # 获取并验证headers self.headers = dict(ws.request.headers) # 获取客户端ip地址 - client_ip = ws.remote_address[0] - self.logger.bind(tag=TAG).info(f"{client_ip} conn - Headers: {self.headers}") + self.client_ip = ws.remote_address[0] + self.logger.bind(tag=TAG).info(f"{self.client_ip} conn - Headers: {self.headers}") # 进行认证 await self.auth.authenticate(self.headers) @@ -148,6 +149,7 @@ class ConnectionHandler: self.welcome_msg["session_id"] = self.session_id await self.websocket.send(json.dumps(self.welcome_msg)) + # 异步初始化 await self.loop.run_in_executor(None, self._initialize_components) # tts 消化线程 @@ -188,7 +190,13 @@ class ConnectionHandler: self.prompt = self.config["prompt"] if self.private_config: self.prompt = self.private_config.private_config.get("prompt", self.prompt) + + self.client_ip_info = get_ip_info(self.client_ip) + self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}") + self.prompt = self.prompt + f"\n我在:{self.client_ip_info}" self.dialogue.put(Message(role="system", content=self.prompt)) + + self.func_handler = FunctionHandler(self.config) def change_system_prompt(self, prompt): self.prompt = prompt diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 4e2ebaf7..63cb5b9c 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -1,11 +1,11 @@ import os import json -from datetime import datetime import yaml import socket import subprocess import logging import re +import requests def get_project_dir(): @@ -24,6 +24,64 @@ def get_local_ip(): except Exception as e: return "127.0.0.1" +def is_private_ip(ip_addr): + """ + Check if an IP address is a private IP address (compatible with IPv4 and IPv6). + + @param {string} ip_addr - The IP address to check. + @return {bool} True if the IP address is private, False otherwise. + """ + try: + # Validate IPv4 or IPv6 address format + if not re.match(r"^(\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", ip_addr): + return False # Invalid IP address format + + # IPv4 private address ranges + if '.' in ip_addr: # IPv4 address + ip_parts = list(map(int, ip_addr.split('.'))) + if ip_parts[0] == 10: + return True # 10.0.0.0/8 range + elif ip_parts[0] == 172 and 16 <= ip_parts[1] <= 31: + return True # 172.16.0.0/12 range + elif ip_parts[0] == 192 and ip_parts[1] == 168: + return True # 192.168.0.0/16 range + elif ip_addr == '127.0.0.1': + return True # Loopback address + elif ip_parts[0] == 169 and ip_parts[1] == 254: + return True # Link-local address 169.254.0.0/16 + else: + return False # Not a private IPv4 address + else: # IPv6 address + ip_addr = ip_addr.lower() + if ip_addr.startswith('fc00:') or ip_addr.startswith('fd00:'): + return True # Unique Local Addresses (FC00::/7) + elif ip_addr == '::1': + return True # Loopback address + elif ip_addr.startswith('fe80:'): + return True # Link-local unicast addresses (FE80::/10) + else: + return False # Not a private IPv6 address + + except (ValueError, IndexError): + return False # IP address format error or insufficient segments + +def get_ip_info(ip_addr): + try: + base_url = "https://freeipapi.com/api/json" + url = base_url if is_private_ip(ip_addr) else f"{base_url}/{ip_addr}" + + resp = requests.get(url).json() + + ip_info = { + "city": resp.get("cityName"), + "region": resp.get("regionName"), + "country": resp.get("countryName") + } + return ip_info + except Exception as e: + logging.error(f"Error getting client ip info: {e}") + return {} + def read_config(config_path): with open(config_path, "r", encoding="utf-8") as file: diff --git a/main/xiaozhi-server/plugins_func/functions/get_weather.py b/main/xiaozhi-server/plugins_func/functions/get_weather.py index 380e2cc4..75c0c1a3 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_weather.py +++ b/main/xiaozhi-server/plugins_func/functions/get_weather.py @@ -1,42 +1,104 @@ import requests from bs4 import BeautifulSoup -from plugins_func.register import register_function,ToolType, ActionResponse, Action +from config.logger import setup_logging +from plugins_func.register import register_function, ToolType, ActionResponse, Action +TAG = __name__ +logger = setup_logging() -get_weather_function_desc = { +GET_WEATHER_FUNCTION_DESC = { "type": "function", "function": { "name": "get_weather", - "description": "获取某个地点的天气,用户应先提供一个位置,比如用户说杭州天气,参数为:zhejiang/hangzhou,比如用户说北京天气怎么样,参数为:beijing/beijing。如果用户只问天气怎么样,参数是:guangdong/guangzhou", + "description": ( + "获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。" + "如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名," + "默认用该地所在省份的省会城市。" + ), "parameters": { "type": "object", "properties": { - "city": { + "location": { "type": "string", - "description": "城市,zhejiang/hangzhou" + "description": "地点名,例如杭州。可选参数,如果不提供则不传" + }, + "lang": { + "type": "string", + "description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN" } }, - "required": [ - "city" - ] + "required": ["lang"] } } } -@register_function('get_weather', get_weather_function_desc, ToolType.WAIT) -def get_weather(city: str): - """ - "获取某个地点的天气,用户应先提供一个位置,\n比如用户说杭州天气,参数为:zhejiang/hangzhou,\n\n比如用户说北京天气怎么样,参数为:beijing/beijing", - city : 城市,zhejiang/hangzhou - """ - url = "https://tianqi.moji.com/weather/china/"+city - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36' - } - response = requests.get(url, headers=headers) - if response.status_code!=200: +HEADERS = { + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36' + ) +} + + +def fetch_city_info(location, api_key): + url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh" + response = requests.get(url, headers=HEADERS).json() + return response.get('location', [])[0] if response.get('location') else None + + +def fetch_weather_page(url): + response = requests.get(url, headers=HEADERS) + return BeautifulSoup(response.text, "html.parser") if response.ok else None + + +def parse_weather_info(soup): + city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True) + + current_abstract = soup.select_one(".c-city-weather-current .current-abstract") + current_abstract = current_abstract.get_text(strip=True) if current_abstract else "未知" + + current_basic = {} + for item in soup.select(".c-city-weather-current .current-basic .current-basic___item"): + parts = item.get_text(strip=True, separator=" ").split(" ") + if len(parts) == 2: + key, value = parts[1], parts[0] + current_basic[key] = value + + temps_list = [] + for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据 + date = row.select_one(".date-bg .date").get_text(strip=True) + temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")] + high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None) + temps_list.append((date, high_temp, low_temp)) + + return city_name, current_abstract, current_basic, temps_list + + +@register_function('get_weather', GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL) +def get_weather(conn, location: str = None, lang: str = "zh_CN"): + api_key = conn.config["plugins"]["get_weather"]["api_key"] + default_location = conn.config["plugins"]["get_weather"]["default_location"] + location = location or conn.client_ip_info.get("city") or default_location + logger.bind(tag=TAG).debug(f"获取天气: {location}") + + city_info = fetch_city_info(location, api_key) + if not city_info: + return ActionResponse(Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None) + + soup = fetch_weather_page(city_info['fxLink']) + if not soup: return ActionResponse(Action.REQLLM, None, "请求失败") - soup = BeautifulSoup(response.text, "html.parser") - weather = soup.find('meta', attrs={'name':'description'})["content"] - weather = weather.replace("墨迹天气", "") - return ActionResponse(Action.REQLLM, weather, None) + + city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup) + weather_report = f"根据下列数据,用{lang}回应用户的查询天气请求:\n{city_name}未来7天天气:\n" + for i, (date, high, low) in enumerate(temps_list): + if high and low: + weather_report += f"{date}: {low}到{high}\n" + weather_report += ( + f"当前天气: {current_abstract}\n" + f"当前天气参数: {current_basic}\n" + f"(确保只报告指定单日的气温范围,除非用户明确要求想要了解多日天气,如果未指定,默认报告今天的温度范围。" + "参数为0的值不需要报告给用户,每次都报告体感温度,根据语境选择合适的参数内容告知用户,并对参数给出相应评价)" + ) + + return ActionResponse(Action.REQLLM, weather_report, None)