diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java index 2b1b5ca8..4fa150c8 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java @@ -197,4 +197,11 @@ public class DeviceController { deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission()); return new Result(); } + + @GetMapping("/call/forward") + @Operation(summary = "转发呼叫请求到网关") + public Result> forwardCallRequest(String callerMac, String targetMac, String callerNickname) { + Map result = deviceService.forwardCallRequest(callerMac, targetMac, callerNickname); + return new Result>().ok(result); + } } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java index ecbf719c..05e9cab4 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/DeviceService.java @@ -133,4 +133,10 @@ public interface DeviceService extends BaseService { */ Object callDeviceTool(String deviceId, String toolName, Map arguments); + /** + * 转发呼叫请求到网关 + * @return 网关响应 {status, message} + */ + Map forwardCallRequest(String callerMac, String targetMac, String callerNickname); + } \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index adabbd95..d509ee8a 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -872,4 +872,70 @@ public class DeviceServiceImpl extends BaseServiceImpl } return null; } + + @Override + public Map forwardCallRequest(String callerMac, String targetMac, String callerNickname) { + Map result = new HashMap<>(); + result.put("status", "error"); + result.put("message", "网关配置缺失"); + + // 从系统参数获取网关配置 + String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true); + String mqttSignatureKey = sysParamsService.getValue("server.mqtt_signature_key", true); + + if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) { + log.error("MQTT网关地址未配置"); + result.put("message", "MQTT网关地址未配置"); + return result; + } + if (StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) { + log.error("MQTT签名密钥未配置"); + result.put("message", "MQTT签名密钥未配置"); + return result; + } + + // 生成token: SHA256(date + secret) + String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()); + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest((dateStr + mqttSignatureKey).getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + String token = hexString.toString(); + + // 构建请求体 + Map body = new HashMap<>(); + body.put("caller_mac", callerMac); + body.put("target_mac", targetMac); + body.put("caller_nickname", callerNickname); + + // 发送请求并获取响应 + String url = "http://" + mqttGatewayUrl + "/api/call/request"; + String response = cn.hutool.http.HttpRequest.post(url) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .body(JSONUtil.toJsonStr(body)) + .timeout(5000) + .execute() + .body(); + + // 解析网关响应 + if (StringUtils.isNotBlank(response)) { + Map gwResult = JSONUtil.parseObj(response); + result.put("status", gwResult.get("status")); + result.put("message", gwResult.get("message")); + } + return result; + } catch (Exception e) { + log.error("转发呼叫请求失败: {}", e.getMessage()); + result.put("message", "呼叫请求转发失败: " + e.getMessage()); + return result; + } + } } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java index 070af9a6..060e2659 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java @@ -88,6 +88,7 @@ public class ShiroConfig { // 将config路径使用server服务过滤器 filterMap.put("/config/**", "server"); filterMap.put("/device/address-book/lookup", "server"); + filterMap.put("/device/call/forward", "server"); filterMap.put("/agent/chat-history/report", "server"); filterMap.put("/agent/chat-history/download/**", "anon"); filterMap.put("/agent/chat-summary/**", "server"); diff --git a/main/manager-api/src/main/resources/db/changelog/202605251425.sql b/main/manager-api/src/main/resources/db/changelog/202605251425.sql index feaebec4..db57d573 100644 --- a/main/manager-api/src/main/resources/db/changelog/202605251425.sql +++ b/main/manager-api/src/main/resources/db/changelog/202605251425.sql @@ -2,8 +2,7 @@ SET @data_exists = (SELECT COUNT(*) FROM ai_model_provider WHERE id = 'SYSTEM_PLUGIN_CALL_DEVICE'); SET @sql = IF(@data_exists = 0, 'INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, -`update_date`) VALUES (''SYSTEM_PLUGIN_CALL_DEVICE'', ''Plugin'', ''call_device'', ''呼叫设备'', ''[{"key":"gateway_url","label":"网关地址","type": -"string","default":"http://127.0.0.1:8007"},{"key":"gateway_secret","label":"网关密钥","type":"string"}]'', 85, 1988490863118454785, ''2026-05-18 +`update_date`) VALUES (''SYSTEM_PLUGIN_CALL_DEVICE'', ''Plugin'', ''call_device'', ''呼叫设备'', ''[]'', 85, 1988490863118454785, ''2026-05-18 12:00:00'', 1988490863118454785, ''2026-05-18 12:00:00'')', 'SELECT ''data already exists, skip'' AS msg'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py index b445c72e..21188019 100644 --- a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py +++ b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py @@ -1,17 +1,21 @@ import time +import uuid import asyncio from typing import Dict, Any, TYPE_CHECKING if TYPE_CHECKING: from core.connection import ConnectionHandler +from core.utils.dialogue import Message +from core.providers.asr.dto.dto import InterfaceType from core.handle.receiveAudioHandle import startToChat from core.handle.reportHandle import enqueue_asr_report from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.textMessageHandler import TextMessageHandler from core.handle.textMessageType import TextMessageType from core.utils.util import remove_punctuation_and_length -from core.providers.asr.dto.dto import InterfaceType +from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType + TAG = __name__ @@ -54,6 +58,25 @@ class ListenTextMessageHandler(TextMessageHandler): original_text ) + # 检查是否是设备呼叫指令 [device_call] + if original_text.startswith("[device_call]"): + # 提取 tag 后的文本 + call_text = original_text[len("[device_call]"):].strip() + conn.logger.bind(tag=TAG).info(f"收到设备呼叫指令: {call_text}") + + # 准备开始新会话 + conn.sentence_id = uuid.uuid4().hex + + await send_stt_message(conn, call_text) + conn.tts.store_tts_text(conn.sentence_id, call_text) + conn.tts.tts_text_queue.put(TTSMessageDTO(sentence_id=conn.sentence_id, sentence_type=SentenceType.FIRST, content_type=ContentType.ACTION)) + conn.tts.tts_one_sentence(conn, ContentType.TEXT, content_detail=call_text) + conn.tts.tts_text_queue.put(TTSMessageDTO(sentence_id=conn.sentence_id, sentence_type=SentenceType.LAST, content_type=ContentType.ACTION)) + + # 添加到对话历史,让模型理解上下文 + conn.dialogue.put(Message(role="user", content=call_text)) + return + # 识别是否是唤醒词 is_wakeup_words = filtered_text in conn.config.get("wakeup_words") # 是否开启唤醒词回复 diff --git a/main/xiaozhi-server/plugins_func/functions/call_device.py b/main/xiaozhi-server/plugins_func/functions/call_device.py index 3e619fd8..03ccccaa 100644 --- a/main/xiaozhi-server/plugins_func/functions/call_device.py +++ b/main/xiaozhi-server/plugins_func/functions/call_device.py @@ -1,8 +1,5 @@ """呼叫设备工具""" -import httpx -import hashlib import requests -from datetime import datetime from typing import TYPE_CHECKING from config.logger import setup_logging @@ -14,121 +11,90 @@ if TYPE_CHECKING: TAG = __name__ logger = setup_logging() - call_device_function_desc = { "type": "function", "function": { "name": "call_device", - "description": "主动呼叫其他小智设备进行语音通话。**重要**:只有当用户主动说\"我想跟XX通话\"、\"帮我打电话给XX\"时才调用此工具。当收到\"来自XX的来电\"消息时,不要调用此工具,让设备自然播报即可。", + "description": "主动呼叫其他小智设备进行语音通话。**重要**:只有当用户主动说'我想跟XX通话'、'帮我打电话给XX'时才调用此工具。当收到'来自XX的来电'消息时,不要调用此工具,让设备自然播报即可。", "parameters": { "type": "object", "properties": { - "nickname": { - "type": "string", - "description": "目标设备的备注名,例如:小陈、小翁" - }, - "response_success": { - "type": "string", - "description": "呼叫成功时的回复" - }, - "response_failure": { - "type": "string", - "description": "呼叫失败时的回复" - } + "nickname": {"type": "string", "description": "目标设备的备注名,例如:小陈、小翁"}, }, - "required": ["nickname", "response_success", "response_failure"], + "required": ["nickname"], }, }, } -def _api_request(url: str, params: dict, headers: dict) -> dict: - """发送API请求并处理响应""" - resp = requests.get(url, params=params, headers=headers, timeout=10) - return resp.json() +def _request_api(url: str, params: dict, headers: dict) -> requests.Response: + return requests.get(url, params=params, headers=headers, timeout=10) -def _call_gateway(gateway_url: str, gateway_secret: str, caller_mac: str, target_mac: str, caller_nickname: str) -> dict: - """呼叫网关""" - date_str = datetime.now().strftime("%Y-%m-%d") - token = hashlib.sha256((date_str + gateway_secret).encode()).hexdigest() - headers = {"Authorization": f"Bearer {token}"} - with httpx.Client(timeout=5.0) as client: - resp = client.post( - f"{gateway_url}/api/call/request", - json={"caller_mac": caller_mac, "target_mac": target_mac, "caller_nickname": caller_nickname}, - headers=headers - ) - return resp.json() +def _failed_reply(msg: str) -> ActionResponse: + return ActionResponse(action=Action.RESPONSE, response=msg) @register_function("call_device", call_device_function_desc, ToolType.SYSTEM_CTL) -def call_device(conn: "ConnectionHandler", nickname: str, response_success: str = None, response_failure: str = None): +def call_device(conn: "ConnectionHandler", nickname: str): + caller_mac = conn.headers.get("device-id") + if not caller_mac: + return _failed_reply("无法获取本机MAC地址") + + api_config = conn.config.get("manager-api", {}) + api_url = api_config.get("url") + api_secret = api_config.get("secret") + if not api_url or not api_secret: + logger.bind(tag=TAG).error("manager-api配置缺失") + return _failed_reply("配置错误,请稍后再试") + + headers = {"Authorization": f"Bearer {api_secret}"} + + # 查询通讯录 try: - caller_mac = conn.headers.get("device-id", "") - if not caller_mac: - return ActionResponse(action=Action.RESPONSE, response="无法获取本机MAC地址") + resp = _request_api( + f"{api_url}/device/address-book/lookup", + params={"callerMac": caller_mac, "nickname": nickname}, + headers=headers, + ) + result = resp.json() + except requests.RequestException as e: + logger.bind(tag=TAG).error(f"通讯录查找请求失败: {e}") + return _failed_reply("通讯录查询失败,请稍后再试") - api_config = conn.config.get("manager-api", {}) - api_url = api_config.get("url", "") - api_secret = api_config.get("secret", "") - if not api_url or not api_secret: - logger.bind(tag=TAG).error("manager-api配置缺失") - return ActionResponse(action=Action.RESPONSE, response="配置错误,请稍后再试") + if result.get("code") != 0 or not result.get("data"): + return _failed_reply(f"未找到备注为'{nickname}'的设备") - try: - result = _api_request( - f"{api_url}/device/address-book/lookup", - params={"callerMac": caller_mac, "nickname": nickname}, - headers={"Authorization": f"Bearer {api_secret}"} - ) - except Exception as e: - logger.bind(tag=TAG).error(f"通讯录查找请求失败: {e}") - return ActionResponse(action=Action.RESPONSE, response="通讯录查询失败,请稍后再试") + data = result["data"] + target_mac = data.get("targetMac") + caller_nickname = data.get("callerNickname") + has_permission = data.get("hasPermission") - if result.get("code") != 0: - return ActionResponse(action=Action.RESPONSE, response=f"未找到备注为'{nickname}'的设备") + if not target_mac: + return _failed_reply(f"未找到备注为'{nickname}'的设备") + if not caller_nickname: + return _failed_reply("呼叫失败,您不是对方的联系人") + if not has_permission: + return _failed_reply("呼叫失败,您没有权限呼叫该设备") - lookup_result = result.get("data") - if not lookup_result: - return ActionResponse(action=Action.RESPONSE, response=f"未找到备注为'{nickname}'的设备") + # 通过Java中转调用网关 + try: + resp = _request_api( + f"{api_url}/device/call/forward", + params={"callerMac": caller_mac, "targetMac": target_mac, "callerNickname": caller_nickname}, + headers=headers, + ) + result = resp.json() + except requests.RequestException as e: + logger.bind(tag=TAG).error(f"呼叫请求转发失败: {e}") + return _failed_reply("呼叫失败,请稍后再试") - target_mac = lookup_result.get("targetMac") - caller_nickname = lookup_result.get("callerNickname") - has_permission = lookup_result.get("hasPermission", "false") == "true" + if result.get("code") != 0: + return _failed_reply(result.get("msg", "呼叫失败")) - if not target_mac: - return ActionResponse(action=Action.RESPONSE, response=f"未找到备注为'{nickname}'的设备") + call_data = result.get("data", {}) + if call_data.get("status") == "offline": + return _failed_reply(call_data.get("message")) - if not caller_nickname: - return ActionResponse(action=Action.RESPONSE, response="呼叫失败,您不是对方的联系人") - - if not has_permission: - return ActionResponse(action=Action.RESPONSE, response="呼叫失败,您没有权限呼叫该设备") - - plugin_config = conn.config.get("plugins", {}).get("call_device", {}) - gateway_url = plugin_config.get("gateway_url", "http://127.0.0.1:8007") - gateway_secret = plugin_config.get("gateway_secret", "") - - try: - gw_result = _call_gateway(gateway_url, gateway_secret, caller_mac, target_mac, caller_nickname) - except httpx.ConnectError: - logger.bind(tag=TAG).error(f"无法连接到网关: {gateway_url}") - return ActionResponse(action=Action.RESPONSE, response="无法连接到通话网关,请稍后再试") - except httpx.TimeoutException: - logger.bind(tag=TAG).error("网关请求超时") - return ActionResponse(action=Action.RESPONSE, response="通话请求超时,请稍后再试") - - conn.calling = True - status = gw_result.get("status") - if status == "bridged": - return ActionResponse(action=Action.NONE, result="bridged", response=f"已与{nickname}建立通话") - elif status == "pending": - return ActionResponse(action=Action.NONE, result="pending", response=f"正在呼叫{nickname},请等待对方接听") - else: - error_msg = gw_result.get("message", "未知错误") - return ActionResponse(action=Action.RESPONSE, response=f"呼叫失败:{error_msg}") - - except Exception as e: - logger.bind(tag=TAG).error(f"call_device错误: {e}") - return ActionResponse(action=Action.ERROR, response=f"呼叫失败: {str(e)}") + conn.calling = True + return ActionResponse(action=Action.NONE, response=f"正在呼叫{nickname},请等待对方接听") \ No newline at end of file