""" 音乐点歌插件 支持两种触发方式: 1. 指令触发:点歌 歌曲名 2. AI函数调用 """ import aiohttp from pathlib import Path import tomllib from typing import List, Optional from loguru import logger from utils.plugin_base import PluginBase from utils.decorators import on_text_message from WechatHook import WechatHookClient class MusicPlugin(PluginBase): """音乐点歌插件""" description = "音乐点歌 - 搜索并播放歌曲" author = "ShiHao" version = "1.0.0" def __init__(self): super().__init__() self.api_url = "https://tunehub.sayqz.com/api" self.api_key = "" self.fallback_api_url = "https://www.hhlqilongzhu.cn/api/dg_wyymusic.php" self.tool_max_results = 3 config_path = Path("plugins/Music/config.toml") if config_path.exists(): try: with open(config_path, "rb") as f: plugin_config = tomllib.load(f) music_config = plugin_config.get("Music", {}) self.api_url = music_config.get("api_url", self.api_url) self.api_key = music_config.get("api_key", self.api_key) self.fallback_api_url = music_config.get("fallback_api_url", self.fallback_api_url) tool_max_results = music_config.get("tool_max_results", self.tool_max_results) try: self.tool_max_results = int(tool_max_results) except (TypeError, ValueError): logger.warning(f"Music tool_max_results 配置无效: {tool_max_results}") except Exception as e: logger.warning(f"读取 Music 配置失败: {e}") self.api_base = self.api_url.rstrip("/") self.api_headers = {"X-API-Key": self.api_key} if self.api_key else {} async def _request_json( self, session: aiohttp.ClientSession, url: str, method: str = "GET", params: Optional[dict] = None, json_body: Optional[dict] = None, timeout: int = 10, headers: Optional[dict] = None, ) -> Optional[dict]: """请求 JSON,带简易重试(处理 502/503/504)""" import asyncio retry_statuses = {502, 503, 504} for attempt in range(3): try: if method.upper() == "POST": req = session.post else: req = session.get async with req( url, params=params, json=json_body, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: if resp.status in retry_statuses and attempt < 2: await asyncio.sleep(0.5 * (attempt + 1)) continue if resp.status != 200: logger.warning(f"HTTP 请求失败: {url} -> {resp.status}") return None try: return await resp.json(content_type=None) except Exception: text = await resp.text() try: import json return json.loads(text) except Exception: logger.warning(f"HTTP 响应不是 JSON: {url}") return None except Exception as e: if attempt < 2: await asyncio.sleep(0.5 * (attempt + 1)) continue logger.warning(f"HTTP 请求异常: {url} -> {e}") return None async def async_init(self): """异步初始化""" logger.success("音乐点歌插件初始化完成") async def _get_method_config( self, session: aiohttp.ClientSession, platform: str, function: str ) -> Optional[dict]: """获取平台方法配置(方法下发)""" url = f"{self.api_base}/v1/methods/{platform}/{function}" result = await self._request_json( session, url, method="GET", headers=self.api_headers, timeout=10 ) if not result or result.get("code") != 0: logger.warning(f"[{platform}] 获取方法失败: {result}") return None return result.get("data") async def _search_platform( self, session: aiohttp.ClientSession, keyword: str, platform: str, page: int = 1, limit: int = 20 ) -> List[dict]: """使用方法下发配置搜索歌曲""" config = await self._get_method_config(session, platform, "search") if not config: return [] headers = config.get("headers", {}) url = config.get("url") method = config.get("method", "GET") if platform == "netease": params = { "s": keyword, "type": "1", "offset": max(page - 1, 0) * limit, "limit": limit, } response = await self._request_json( session, url, method=method, params=params, headers=headers, timeout=10 ) songs = (response or {}).get("result", {}).get("songs") or [] results = [] for item in songs: results.append({ "id": str(item.get("id")), "name": item.get("name"), "artist": ", ".join([a.get("name") for a in item.get("artists", [])]), "album": (item.get("album") or {}).get("name", ""), "platform": platform, }) return results if platform == "qq": import copy body = copy.deepcopy(config.get("body", {})) body.setdefault("req", {}).setdefault("param", {}) body["req"]["param"]["query"] = keyword body["req"]["param"]["page_num"] = page body["req"]["param"]["num_per_page"] = limit response = await self._request_json( session, url, method="POST", json_body=body, headers=headers, timeout=10 ) song_list = (((response or {}).get("req") or {}).get("data") or {}).get("body", {}) song_list = song_list.get("song", {}).get("list", []) or [] results = [] for item in song_list: results.append({ "id": item.get("mid"), "name": item.get("name"), "artist": ", ".join([s.get("name") for s in item.get("singer", [])]), "album": (item.get("album") or {}).get("name", ""), "platform": platform, }) return results if platform == "kuwo": params = config.get("params", {}).copy() params["all"] = keyword params["pn"] = max(page - 1, 0) params["rn"] = limit response = await self._request_json( session, url, method=method, params=params, headers=headers, timeout=10 ) song_list = (response or {}).get("abslist") or [] results = [] for item in song_list: results.append({ "id": str(item.get("MUSICRID", "")).replace("MUSIC_", ""), "name": item.get("SONGNAME"), "artist": (item.get("ARTIST") or "").replace("&", ", "), "album": item.get("ALBUM") or "", "platform": platform, }) return results logger.warning(f"未知平台: {platform}") return [] async def search_music_fallback( self, session: aiohttp.ClientSession, keyword: str ) -> Optional[dict]: """备用搜索(仅网易云),用于主源不可用时兜底""" params = { "gm": keyword, "n": 1, "br": 2, "type": "json", } result = await self._request_json(session, self.fallback_api_url, params=params, timeout=10) if not result: return None if result.get("code") != 200: logger.warning(f"[fallback] 搜索失败: {result}") return None name = result.get("title") or keyword artist = result.get("singer") or "未知歌手" music_url = (result.get("music_url") or "").split("?")[0] link_url = result.get("link") or "" cover_url = result.get("cover") or "" if not music_url and not link_url: return None return { "name": name, "artist": artist, "url": music_url or link_url, "pic": cover_url, "platform": "netease", } async def search_music(self, keyword: str) -> List[dict]: """ 从三个平台搜索音乐 Args: keyword: 歌曲关键词 Returns: 歌曲信息列表 """ import asyncio sources = ["netease", "qq", "kuwo"] if not self.api_key: logger.warning("Music API Key 未配置,无法使用 TuneHub") async with aiohttp.ClientSession() as session: fallback = await self.search_music_fallback(session, keyword) return [fallback] if fallback else [] async with aiohttp.ClientSession() as session: tasks = [self._search_platform(session, keyword, source) for source in sources] results = await asyncio.gather(*tasks, return_exceptions=True) songs = [] for result in results: if isinstance(result, list): songs.extend([item for item in result if isinstance(item, dict) and item.get("id")]) if songs: return songs fallback = await self.search_music_fallback(session, keyword) if fallback: logger.info("主源不可用,已使用备用网易云搜索") return [fallback] return [] async def get_real_url(self, redirect_url: str) -> str: """ 获取重定向后的真实 URL Args: redirect_url: 重定向链接 Returns: 真实 URL """ try: async with aiohttp.ClientSession() as session: async with session.get( redirect_url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=5) ) as resp: return str(resp.url) except Exception as e: logger.warning(f"获取真实URL失败: {e}") return redirect_url async def parse_song( self, session: aiohttp.ClientSession, platform: str, song_id: str, quality: str = "320k" ) -> Optional[dict]: """调用解析接口获取真实播放链接与歌词""" if not self.api_key: logger.warning("Music API Key 未配置,无法解析歌曲") return None body = { "platform": platform, "ids": str(song_id), "quality": quality, } url = f"{self.api_base}/v1/parse" result = await self._request_json( session, url, method="POST", json_body=body, headers=self.api_headers, timeout=20 ) if not result or result.get("code") != 0: logger.warning(f"[{platform}] 解析失败: {result}") return None data = (result.get("data") or {}).get("data") or [] if not data: return None item = data[0] if not item.get("success", True): logger.warning(f"[{platform}] 解析失败: {item}") return None return item async def send_music_chat_record(self, bot: WechatHookClient, to_wxid: str, keyword: str, songs: list): """ 以聊天记录格式发送音乐卡片 Args: bot: 机器人客户端 to_wxid: 接收者 wxid keyword: 搜索关键词 songs: 歌曲列表 """ try: import uuid import time import hashlib import xml.etree.ElementTree as ET is_group = to_wxid.endswith("@chatroom") # 构造聊天记录 XML recordinfo = ET.Element("recordinfo") ET.SubElement(recordinfo, "info").text = f"🎵 {keyword}" ET.SubElement(recordinfo, "isChatRoom").text = "1" if is_group else "0" datalist = ET.SubElement(recordinfo, "datalist") datalist.set("count", str(len(songs))) ET.SubElement(recordinfo, "desc").text = f"{keyword} 音乐" ET.SubElement(recordinfo, "fromscene").text = "3" appid_map = { "netease": "wx8dd6ecd81906fd84", "qq": "wx45116b30f23e0cc4", "kuwo": "wxc305711a2a7ad71c" } async with aiohttp.ClientSession() as session: for song in songs: name = song.get("name", "未知歌曲") artist = song.get("artist", "未知歌手") platform = song.get("platform", "unknown") url_redirect = song.get("url", "") pic_redirect = song.get("pic", "") if not url_redirect and song.get("id"): parsed = await self.parse_song(session, platform, song.get("id")) if parsed: info = parsed.get("info") or {} name = info.get("name") or name artist = info.get("artist") or artist url_redirect = parsed.get("url") or url_redirect pic_redirect = parsed.get("cover") or pic_redirect # 获取真实 URL url = await self.get_real_url(url_redirect) if url_redirect else "" pic = await self.get_real_url(pic_redirect) if pic_redirect else "" appid = appid_map.get(platform, "wx8dd6ecd81906fd84") # 构造音乐卡片 XML music_xml = f"""{name}{artist}view30{url}{url}{url}{url}{pic}000{pic}""" di = ET.SubElement(datalist, "dataitem") di.set("datatype", "49") # 49=appmsg(应用消息) di.set("dataid", uuid.uuid4().hex) ET.SubElement(di, "srcMsgLocalid").text = str((int(time.time() * 1000) % 90000) + 10000) ET.SubElement(di, "sourcetime").text = time.strftime("%Y-%m-%d %H:%M") ET.SubElement(di, "fromnewmsgid").text = str(int(time.time() * 1000)) ET.SubElement(di, "srcMsgCreateTime").text = str(int(time.time())) ET.SubElement(di, "sourcename").text = f"{name}" ET.SubElement(di, "sourceheadurl").text = pic ET.SubElement(di, "datatitle").text = f"{name} - {artist}" ET.SubElement(di, "datadesc").text = artist ET.SubElement(di, "datafmt").text = "appmsg" ET.SubElement(di, "ischatroom").text = "1" if is_group else "0" # 使用 CDATA 包裹音乐 XML appmsg_elem = ET.SubElement(di, "appmsg") appmsg_elem.text = f"" dataitemsource = ET.SubElement(di, "dataitemsource") ET.SubElement(dataitemsource, "hashusername").text = hashlib.sha256(to_wxid.encode("utf-8")).hexdigest() record_xml = ET.tostring(recordinfo, encoding="unicode") appmsg_xml = f"""🎵 {keyword}{keyword} 音乐19https://support.weixin.qq.com/cgi-bin/mmsupport-bin/readtemplate?t=page/favorite_record__w_unsupport0""" await bot.send_xml(to_wxid, appmsg_xml) logger.success(f"已发送音乐聊天记录: {len(songs)} 首") except Exception as e: logger.error(f"发送音乐聊天记录失败: {e}") async def send_music_card(self, bot: WechatHookClient, to_wxid: str, song: dict, retry: int = 2): """ 发送音乐卡片 Args: bot: 机器人客户端 to_wxid: 接收者 wxid song: 歌曲信息 retry: 重试次数 """ try: name = song.get("name", "未知歌曲") artist = song.get("artist", "未知歌手") platform = song.get("platform", "unknown") song_id = song.get("id") logger.info(f"准备发送音乐卡片: {name} - {artist} (平台: {platform})") url = "" pic = "" if song_id: async with aiohttp.ClientSession() as session: parsed = await self.parse_song(session, platform, song_id) if parsed: info = parsed.get("info") or {} name = info.get("name") or name artist = info.get("artist") or artist url = parsed.get("url") or "" pic = parsed.get("cover") or "" # 解析失败时尝试使用搜索结果的直链 if not url: url_redirect = song.get("url", "") if url_redirect: try: url = await self.get_real_url(url_redirect) except Exception as e: logger.warning(f"获取播放链接失败,使用原链接: {e}") if not pic: pic_redirect = song.get("pic", "") if pic_redirect: try: pic = await self.get_real_url(pic_redirect) except Exception as e: logger.warning(f"获取封面失败,使用空封面: {e}") if not url: logger.warning(f"未获取到播放链接: {name} - {artist}") return False # 歌词字段留空(避免XML过大) lrc = "" # 根据平台选择 appid appid_map = { "netease": "wx8dd6ecd81906fd84", "qq": "wx45116b30f23e0cc4", "kuwo": "wxc305711a2a7ad71c" } appid = appid_map.get(platform, "wx8dd6ecd81906fd84") # 构造音乐卡片 XML xml = f""" {name} {artist} view 3 0 {url} {url} {url} {url} {pic} {lrc} 0 0 0 {pic} """ result = await bot.send_xml(to_wxid, xml) if result: logger.success(f"已发送音乐卡片: {name} - {artist}") else: # 发送失败,尝试重试 if retry > 0: logger.warning(f"发送失败,{retry}秒后重试: {name}") import asyncio await asyncio.sleep(1) await self.send_music_card(bot, to_wxid, song, retry - 1) else: logger.error(f"发送音乐卡片失败(已重试): {name} - {artist}") except Exception as e: logger.error(f"发送音乐卡片异常: {e}") def _select_tool_songs(self, songs: List[dict]) -> List[dict]: """为工具调用挑选少量歌曲,避免刷屏。""" if not songs: return [] max_results = max(1, int(self.tool_max_results)) return list(songs[:max_results]) @on_text_message(priority=60) async def handle_music_command(self, bot: WechatHookClient, message: dict): """处理点歌指令""" content = message.get("Content", "").strip() from_wxid = message.get("FromWxid", "") # 精确匹配 "点歌 " 开头 if not content.startswith("点歌 "): return True # 提取歌曲名和参数 parts = content[3:].strip().split() if not parts: await bot.send_text(from_wxid, "❌ 请输入歌曲名\n格式:点歌 歌曲名 [3]") return False # 检查是否要发送全部平台 send_all = len(parts) > 1 and parts[-1] == "3" keyword = " ".join(parts[:-1]) if send_all else " ".join(parts) logger.info(f"点歌: {keyword}, 发送全部: {send_all}") # 从三个平台搜索歌曲 songs = await self.search_music(keyword) if not songs: await bot.send_text(from_wxid, f"❌ 未找到歌曲:{keyword}") return False # 根据参数决定发送哪些 if send_all: # 发送所有平台(添加延迟避免限流) import asyncio for i, song in enumerate(songs): await self.send_music_card(bot, from_wxid, song) if i < len(songs) - 1: await asyncio.sleep(2) else: # 只发送 QQ 音乐 qq_song = next((s for s in songs if s.get("platform") == "qq"), None) if qq_song: await self.send_music_card(bot, from_wxid, qq_song) else: # 如果没有 QQ 音乐,发送第一个 await self.send_music_card(bot, from_wxid, songs[0]) logger.success(f"已发送歌曲") return False def get_llm_tools(self) -> List[dict]: """返回 LLM 工具定义""" return [ { "type": "function", "function": { "name": "search_music", "description": ( "根据歌曲名/歌手名检索并发送音乐卡片。" "仅当用户明确提出点歌、听歌、播放某首歌时调用;" "如果只是询问歌词出处,优先先确认歌名再决定是否点歌。" ), "parameters": { "type": "object", "properties": { "keyword": { "type": "string", "description": "歌曲检索词,可为歌名、歌手名或二者组合,如“周杰伦 晴天”。" } }, "required": ["keyword"], "additionalProperties": False } } } ] async def execute_llm_tool(self, tool_name: str, arguments: dict, bot, from_wxid: str) -> dict: """执行 LLM 工具调用""" try: if tool_name != "search_music": return None keyword = arguments.get("keyword") if not keyword: return {"success": False, "message": "缺少歌曲名称参数"} logger.info(f"AI 调用点歌: {keyword}") await bot.send_text(from_wxid, f"🔍 正在搜索:{keyword}") # 从三个平台搜索歌曲 songs = await self.search_music(keyword) if not songs: await bot.send_text(from_wxid, f"❌ 未找到歌曲:{keyword}") return {"success": False, "message": f"未找到歌曲:{keyword}"} # 工具调用默认只发送少量结果,避免刷屏 import asyncio selected_songs = self._select_tool_songs(songs) for i, song in enumerate(selected_songs): await self.send_music_card(bot, from_wxid, song) if i < len(selected_songs) - 1: # 最后一个不需要延迟 await asyncio.sleep(2) # 每条消息间隔2秒 return {"success": True, "message": f"已发送 {len(selected_songs)} 首歌曲"} except Exception as e: logger.error(f"LLM 工具执行失败: {e}") return {"success": False, "message": f"执行失败: {str(e)}"}