chore: sync current WechatHookBot workspace

This commit is contained in:
2026-03-09 15:48:45 +08:00
parent 4016c1e6eb
commit 9119e2307d
195 changed files with 24438 additions and 17498 deletions

View File

@@ -0,0 +1,6 @@
[Music]
enabled = true
api_url = "https://tunehub.sayqz.com/api"
api_key = "th_7abc9cad5af235b2c78112b4e74bbee2466b2c1d39a6e9ee"
fallback_api_url = "https://www.hhlqilongzhu.cn/api/dg_wyymusic.php"
tool_max_results = 3

View File

@@ -8,6 +8,7 @@
import aiohttp
from pathlib import Path
import tomllib
from typing import List, Optional
from loguru import logger
from utils.plugin_base import PluginBase
@@ -24,59 +25,241 @@ class MusicPlugin(PluginBase):
def __init__(self):
super().__init__()
self.api_url = "https://music-dl.sayqz.com/api/"
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 search_music_from_source(self, keyword: str, source: str) -> Optional[dict]:
"""
从指定平台搜索音乐
Args:
keyword: 歌曲关键词
source: 音乐平台 (netease/qq/kuwo)
Returns:
歌曲信息字典,失败返回 None
"""
params = {
"source": source,
"keyword": keyword,
"type": "search"
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(
self.api_url,
params=params,
timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status != 200:
logger.warning(f"[{source}] 搜索失败: HTTP {resp.status}")
return None
result = await resp.json()
if result.get("code") != 200:
logger.warning(f"[{source}] 搜索失败: {result.get('message')}")
return None
data = result.get("data", {})
results = data.get("results", [])
if not results:
logger.warning(f"[{source}] 未找到歌曲: {keyword}")
return None
# 返回第一个结果
return results[0]
except Exception as e:
logger.warning(f"[{source}] 搜索异常: {e}")
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]:
"""
@@ -90,17 +273,31 @@ class MusicPlugin(PluginBase):
"""
import asyncio
sources = ["netease", "qq", "kuwo"] # 恢复qq用于调试
tasks = [self.search_music_from_source(keyword, source) for source in sources]
results = await asyncio.gather(*tasks, return_exceptions=True)
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 []
# 过滤掉失败的结果
songs = []
for result in results:
if isinstance(result, dict) and result:
songs.append(result)
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)
return songs
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:
"""
@@ -124,6 +321,47 @@ class MusicPlugin(PluginBase):
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):
"""
以聊天记录格式发送音乐卡片
@@ -151,52 +389,62 @@ class MusicPlugin(PluginBase):
ET.SubElement(recordinfo, "desc").text = f"{keyword} 音乐"
ET.SubElement(recordinfo, "fromscene").text = "3"
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", "")
appid_map = {
"netease": "wx8dd6ecd81906fd84",
"qq": "wx45116b30f23e0cc4",
"kuwo": "wxc305711a2a7ad71c"
}
# 获取真实 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 ""
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", "")
# 根据平台选择 appid
appid_map = {
"netease": "wx8dd6ecd81906fd84",
"qq": "wx45116b30f23e0cc4",
"kuwo": "wxc305711a2a7ad71c"
}
appid = appid_map.get(platform, "wx8dd6ecd81906fd84")
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
# 构造音乐卡片 XML
music_xml = f"""<appmsg appid="{appid}" sdkver="0"><title>{name}</title><des>{artist}</des><action>view</action><type>3</type><showtype>0</showtype><content/><url>{url}</url><dataurl>{url}</dataurl><lowurl>{url}</lowurl><lowdataurl>{url}</lowdataurl><recorditem/><thumburl>{pic}</thumburl><messageaction/><laninfo/><extinfo/><sourceusername/><sourcedisplayname/><songlyric></songlyric><commenturl/><appattach><totallen>0</totallen><attachid/><emoticonmd5/><fileext/><aeskey/></appattach><webviewshared><publisherId/><publisherReqId>0</publisherReqId></webviewshared><weappinfo><pagepath/><username/><appid/><appservicetype>0</appservicetype></weappinfo><websearch/><songalbumurl>{pic}</songalbumurl></appmsg>"""
# 获取真实 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 ""
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"<![CDATA[{music_xml}]]>"
dataitemsource = ET.SubElement(di, "dataitemsource")
ET.SubElement(dataitemsource, "hashusername").text = hashlib.sha256(to_wxid.encode("utf-8")).hexdigest()
appid = appid_map.get(platform, "wx8dd6ecd81906fd84")
# 构造音乐卡片 XML
music_xml = f"""<appmsg appid="{appid}" sdkver="0"><title>{name}</title><des>{artist}</des><action>view</action><type>3</type><showtype>0</showtype><content/><url>{url}</url><dataurl>{url}</dataurl><lowurl>{url}</lowurl><lowdataurl>{url}</lowdataurl><recorditem/><thumburl>{pic}</thumburl><messageaction/><laninfo/><extinfo/><sourceusername/><sourcedisplayname/><songlyric></songlyric><commenturl/><appattach><totallen>0</totallen><attachid/><emoticonmd5/><fileext/><aeskey/></appattach><webviewshared><publisherId/><publisherReqId>0</publisherReqId></webviewshared><weappinfo><pagepath/><username/><appid/><appservicetype>0</appservicetype></weappinfo><websearch/><songalbumurl>{pic}</songalbumurl></appmsg>"""
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"<![CDATA[{music_xml}]]>"
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"""<appmsg appid="" sdkver="0"><title>🎵 {keyword}</title><des>{keyword} 音乐</des><type>19</type><url>https://support.weixin.qq.com/cgi-bin/mmsupport-bin/readtemplate?t=page/favorite_record__w_unsupport</url><appattach><cdnthumbaeskey></cdnthumbaeskey><aeskey></aeskey></appattach><recorditem><![CDATA[{record_xml}]]></recorditem><percent>0</percent></appmsg>"""
await bot._send_data_async(11214, {"to_wxid": to_wxid, "content": appmsg_xml})
await bot.send_xml(to_wxid, appmsg_xml)
logger.success(f"已发送音乐聊天记录: {len(songs)}")
except Exception as e:
@@ -215,27 +463,44 @@ class MusicPlugin(PluginBase):
try:
name = song.get("name", "未知歌曲")
artist = song.get("artist", "未知歌手")
url_redirect = song.get("url", "")
pic_redirect = song.get("pic", "")
platform = song.get("platform", "unknown")
song_id = song.get("id")
logger.info(f"准备发送音乐卡片: {name} - {artist} (平台: {platform})")
# 获取真实播放 URL失败则使用原链接
url = url_redirect
if url_redirect:
try:
url = await self.get_real_url(url_redirect)
except Exception as e:
logger.warning(f"获取播放链接失败,使用原链接: {e}")
# 获取真实封面图片 URL失败则使用空字符串
url = ""
pic = ""
if pic_redirect:
try:
pic = await self.get_real_url(pic_redirect)
except Exception as e:
logger.warning(f"获取封面失败,使用空封面: {e}")
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 = ""
@@ -290,7 +555,7 @@ class MusicPlugin(PluginBase):
<songalbumurl>{pic}</songalbumurl>
</appmsg>"""
result = await bot._send_data_async(11214, {"to_wxid": to_wxid, "content": xml})
result = await bot.send_xml(to_wxid, xml)
if result:
logger.success(f"已发送音乐卡片: {name} - {artist}")
@@ -307,6 +572,13 @@ class MusicPlugin(PluginBase):
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):
"""处理点歌指令"""
@@ -362,16 +634,21 @@ class MusicPlugin(PluginBase):
"type": "function",
"function": {
"name": "search_music",
"description": "仅当用户明确要求“点歌/听歌/播放某首歌”时调用;如果只是问歌词出处,先用搜索确认歌名再点歌。",
"description": (
"根据歌曲名/歌手名检索并发送音乐卡片。"
"仅当用户明确提出点歌、听歌、播放某首歌时调用;"
"如果只是询问歌词出处,优先先确认歌名再决定是否点歌。"
),
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "歌曲名称或关键词,例如:告白气球、周杰伦 晴天"
"description": "歌曲检索词,可为歌名、歌手名或二者组合,如“周杰伦 晴天”。"
}
},
"required": ["keyword"]
"required": ["keyword"],
"additionalProperties": False
}
}
}
@@ -396,14 +673,15 @@ class MusicPlugin(PluginBase):
await bot.send_text(from_wxid, f"❌ 未找到歌曲:{keyword}")
return {"success": False, "message": f"未找到歌曲:{keyword}"}
# 发送所有找到的音乐卡片(添加延迟避免限流)
# 工具调用默认只发送少量结果,避免刷屏
import asyncio
for i, song in enumerate(songs):
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(songs) - 1: # 最后一个不需要延迟
if i < len(selected_songs) - 1: # 最后一个不需要延迟
await asyncio.sleep(2) # 每条消息间隔2秒
return {"success": True, "message": f"找到 {len(songs)} 首歌曲"}
return {"success": True, "message": f"发送 {len(selected_songs)} 首歌曲"}
except Exception as e:
logger.error(f"LLM 工具执行失败: {e}")