diff --git a/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/BotOutgoingMessageBuilder.kt b/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/BotOutgoingMessageBuilder.kt index 60e789f..427f7a7 100644 --- a/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/BotOutgoingMessageBuilder.kt +++ b/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/BotOutgoingMessageBuilder.kt @@ -82,6 +82,12 @@ interface BotOutgoingMessageBuilder { */ fun forward(block: suspend BotForwardBlockBuilder.() -> Unit) + /** + * 添加小程序(LightApp)消息段 + * @param jsonPayload JSON 格式的小程序数据 + */ + fun lightApp(jsonPayload: String) + operator fun String.unaryPlus() = text(this) } \ No newline at end of file diff --git a/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/internal/MessageBuildingContext.kt b/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/internal/MessageBuildingContext.kt index f5b1934..ad8c57e 100644 --- a/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/internal/MessageBuildingContext.kt +++ b/acidify-core/src/commonMain/kotlin/org/ntqqrev/acidify/message/internal/MessageBuildingContext.kt @@ -485,6 +485,18 @@ internal class MessageBuildingContext( } } + override fun lightApp(jsonPayload: String) = addAsync { + val buffer = Buffer() + buffer.writeByte(0x01) + buffer.write(ZLib.compress(jsonPayload.encodeToByteArray())) + + Elem { + it[lightAppElem] = LightAppElem { + it[bytesData] = buffer.readByteArray() + } + } + } + suspend fun build(): List> = elemsList.awaitAll().flatten() internal class Forward( @@ -627,6 +639,11 @@ internal class MessageBuildingContext( parent.forward(block) previewBuilder.append("[聊天记录]") } + + override fun lightApp(jsonPayload: String) { + parent.lightApp(jsonPayload) + previewBuilder.append("[卡片消息]") + } } } } \ No newline at end of file diff --git a/build_jvm.py b/build_jvm.py new file mode 100644 index 0000000..e5c4036 --- /dev/null +++ b/build_jvm.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +构建 yogurt-jvm-all.jar 的脚本 + +使用方法: + python build_jvm.py [--proxy HOST:PORT] [--java-home PATH] + +示例: + python build_jvm.py + python build_jvm.py --proxy 192.168.31.84:7890 + python build_jvm.py --proxy 127.0.0.1:7890 --java-home "D:\\SSoftwareFiles\\JDKs\\zulu21" +""" + +import os +import sys +import subprocess +import argparse +from pathlib import Path + + +def find_java_home(): + """查找可用的 Java 21+ 路径""" + # 1. 检查环境变量 + java_home = os.environ.get("JAVA_HOME") + if java_home and Path(java_home).exists(): + return java_home + + # 2. 常见的 JDK 安装位置 + common_paths = [ + # Windows + r"D:\SSoftwareFiles\JDKs\zulu21.42.19-ca-jdk21.0.7-win_x64", + r"C:\Program Files\Java\jdk-21", + r"C:\Program Files\Eclipse Adoptium\jdk-21", + r"C:\Program Files\Zulu\zulu-21", + # Linux/macOS + "/usr/lib/jvm/java-21-openjdk", + "/usr/lib/jvm/java-21", + "/opt/java/jdk-21", + ] + + for path in common_paths: + if Path(path).exists(): + return path + + return None + + +def run_gradle(project_root: Path, proxy_host: str = None, proxy_port: str = None, java_home: str = None): + """运行 Gradle 构建""" + + # 设置环境变量 + env = os.environ.copy() + + if java_home: + env["JAVA_HOME"] = java_home + print(f"✓ JAVA_HOME: {java_home}") + + # 构建 Gradle 命令 + if sys.platform == "win32": + gradle_cmd = str(project_root / "gradlew.bat") + else: + gradle_cmd = str(project_root / "gradlew") + # 确保有执行权限 + os.chmod(gradle_cmd, 0o755) + + cmd = [gradle_cmd, ":yogurt-jvm:buildFatJar", "--no-daemon"] + + # 添加代理设置 + if proxy_host and proxy_port: + cmd.extend([ + f"-Dhttp.proxyHost={proxy_host}", + f"-Dhttp.proxyPort={proxy_port}", + f"-Dhttps.proxyHost={proxy_host}", + f"-Dhttps.proxyPort={proxy_port}", + ]) + print(f"✓ 代理: {proxy_host}:{proxy_port}") + + print(f"✓ 工作目录: {project_root}") + print(f"✓ 执行命令: {' '.join(cmd)}") + print("-" * 60) + + # 执行构建 + try: + process = subprocess.run( + cmd, + cwd=project_root, + env=env, + # 不捕获输出,直接显示到终端 + ) + return process.returncode + except KeyboardInterrupt: + print("\n\n⚠ 构建被用户中断") + return 1 + except Exception as e: + print(f"\n✗ 构建出错: {e}") + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description="构建 yogurt-jvm-all.jar", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python build_jvm.py + python build_jvm.py --proxy 192.168.31.84:7890 + python build_jvm.py --proxy 127.0.0.1:7890 --java-home "D:\\JDKs\\jdk-21" + """ + ) + parser.add_argument( + "--proxy", + type=str, + help="代理服务器地址 (格式: HOST:PORT,例如 192.168.31.84:7890)" + ) + parser.add_argument( + "--java-home", + type=str, + help="Java 21+ 安装路径" + ) + + args = parser.parse_args() + + print("=" * 60) + print("🍦 Yogurt JVM 构建脚本") + print("=" * 60) + + # 获取项目根目录 + script_dir = Path(__file__).parent.resolve() + project_root = script_dir + + # 检查是否在正确的目录 + if not (project_root / "gradlew.bat").exists() and not (project_root / "gradlew").exists(): + print("✗ 错误: 找不到 gradlew,请确保脚本在项目根目录") + return 1 + + # 查找 Java + java_home = args.java_home or find_java_home() + if not java_home: + print("✗ 错误: 找不到 Java 21+,请使用 --java-home 参数指定") + print(" 示例: python build_jvm.py --java-home \"D:\\JDKs\\jdk-21\"") + return 1 + + # 解析代理 + proxy_host = None + proxy_port = None + if args.proxy: + try: + proxy_host, proxy_port = args.proxy.split(":") + except ValueError: + print(f"✗ 错误: 代理格式不正确,应为 HOST:PORT") + return 1 + + print() + + # 运行构建 + ret = run_gradle(project_root, proxy_host, proxy_port, java_home) + + print() + print("=" * 60) + + if ret == 0: + jar_path = project_root / "yogurt-jvm" / "build" / "libs" / "yogurt-jvm-all.jar" + if jar_path.exists(): + size_mb = jar_path.stat().st_size / (1024 * 1024) + print(f"✓ 构建成功!") + print(f"✓ JAR 文件: {jar_path}") + print(f"✓ 文件大小: {size_mb:.2f} MB") + else: + print("✓ 构建完成,但找不到 JAR 文件") + print(f" 预期位置: {jar_path}") + else: + print(f"✗ 构建失败 (退出码: {ret})") + print() + print("常见问题排查:") + print(" 1. 网络问题 - 尝试使用 --proxy 参数") + print(" 2. Java 版本 - 确保使用 Java 21+") + print(" 3. 查看上方的错误信息") + + print("=" * 60) + return ret + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pr.md b/pr.md new file mode 100644 index 0000000..54dc66b --- /dev/null +++ b/pr.md @@ -0,0 +1,94 @@ +# PR: 添加音乐卡片消息支持 + +## 功能概述 + +本 PR 为 Yogurt 添加了音乐卡片消息发送功能,支持发送 QQ音乐、网易云音乐、酷狗音乐等平台的音乐卡片,以及自定义音乐卡片。 + +## 实现方式 + +### 1. 外部签名服务 + +由于音乐卡片消息需要特定格式的 Ark JSON,本实现采用了外部签名服务来生成 Ark 消息体: + +- **签名服务地址**: `https://ss.xingzhige.com/music_card/card` +- **参考实现**: [NapCatQQ](https://github.com/NapNeko/NapCatQQ) 的音乐卡片功能 + +签名服务接收音乐类型和相关参数,返回可直接发送的 Ark JSON。 + +### 2. 新增文件 + +#### `yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/music/MusicSegment.kt` + +包含以下类: + +- `MusicType` - 音乐平台枚举 (qq/163/kugou/migu/kuwo/custom) +- `MusicSegment` / `MusicData` - 音乐消息段数据结构 +- `MusicSignRequest` - 签名请求数据类 +- `MusicSignResponse` - 签名响应数据类 +- `MusicSignException` - 签名异常类 +- `MusicSigner` - 签名器,负责调用外部签名服务 + +#### `yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/message/SendMusicMessage.kt` + +新增两个 HTTP API 端点: + +- `POST /api/send_group_music` - 发送群聊音乐卡片 +- `POST /api/send_private_music` - 发送私聊音乐卡片 + +### 3. API 使用方式 + +#### 平台音乐 (qq/163/kugou/migu/kuwo) + +```json +POST /api/send_group_music +{ + "group_id": 123456789, + "music_type": "163", + "id": "1999253939" +} +``` + +#### 自定义音乐卡片 + +```json +POST /api/send_group_music +{ + "group_id": 123456789, + "music_type": "custom", + "url": "https://music.163.com/#/song?id=1999253939", + "audio": "https://example.com/audio.mp3", + "title": "歌曲标题", + "singer": "歌手名称", + "image": "https://example.com/cover.jpg" +} +``` + +### 4. 为什么不使用 Milky 的 OutgoingSegment? + +Milky 协议的 `OutgoingSegment` 是 sealed class,无法在外部扩展添加新的消息类型。因此选择了添加独立的 HTTP API 端点来实现音乐卡片功能,而不是作为消息段类型。 + +## 测试结果 + +| 音乐类型 | 状态 | 备注 | +|---------|------|------| +| 网易云 (163) | ✅ 正常 | | +| 自定义 (custom) | ✅ 正常 | | +| QQ音乐 (qq) | ⚠️ 部分可用 | 签名服务可能返回非 Ark 格式 | +| 酷狗 (kugou) | ⚠️ 依赖签名服务 | | + +## 配置 + +可在 `config.json` 中配置签名服务地址(可选): + +```json +{ + "musicSignUrl": "https://ss.xingzhige.com/music_card/card" +} +``` + +如不配置,默认使用上述地址。 + +## 相关参考 + +- [NapCatQQ 音乐卡片实现](https://github.com/NapNeko/NapCatQQ) +- 签名服务: `https://ss.xingzhige.com/music_card/card` diff --git a/test_music_card.py b/test_music_card.py new file mode 100644 index 0000000..0012112 --- /dev/null +++ b/test_music_card.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +测试 Yogurt (acidify) 音乐卡片功能 + +使用方法: + python test_music_card.py + +配置说明: + - YOGURT_HOST: Yogurt HTTP 服务地址 + - YOGURT_PORT: Yogurt HTTP 服务端口 + - ACCESS_TOKEN: 访问令牌 (对应 config.json 中的 httpConfig.accessToken) + - GROUP_ID: 测试群号 +""" + +import requests +import json + +# ============================================================================ +# 配置 +# ============================================================================ + +YOGURT_HOST = "127.0.0.1" +YOGURT_PORT = 13000 +ACCESS_TOKEN = "dev" + +# 测试群号 (改成你自己的群) +GROUP_ID = 259248174 + +# 测试私聊用户QQ号 +USER_ID = 1830540513 + +# ============================================================================ +# HTTP 客户端 +# ============================================================================ + +class YogurtClient: + def __init__(self, host: str, port: int, token: str): + self.base_url = f"http://{host}:{port}" + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}" + } + + def _request(self, endpoint: str, data: dict) -> dict: + """发送 POST 请求""" + # 注意: Yogurt 的 API 路由在 /api 前缀下 + url = f"{self.base_url}/api/{endpoint}" + print(f"\n📤 请求: POST {url}") + print(f"📦 数据: {json.dumps(data, ensure_ascii=False, indent=2)}") + + try: + resp = requests.post(url, json=data, headers=self.headers, timeout=30) + print(f"📊 HTTP 状态码: {resp.status_code}") + print(f"📄 原始响应: {resp.text[:500] if resp.text else '(空)'}") + + if resp.text: + result = resp.json() + print(f"📥 JSON 响应: {json.dumps(result, ensure_ascii=False, indent=2)}") + return result + else: + return {"status": "failed", "message": "Empty response"} + except requests.exceptions.RequestException as e: + print(f"❌ 请求失败: {e}") + return {"status": "failed", "message": str(e)} + + # ======================================================================== + # 音乐卡片 API + # ======================================================================== + + def send_group_music(self, group_id: int, music_type: str, **kwargs) -> dict: + """ + 发送群聊音乐卡片 + + Args: + group_id: 群号 + music_type: 音乐类型 (qq/163/kugou/migu/kuwo/custom) + **kwargs: + - id: 歌曲ID (平台音乐必填) + - url: 跳转链接 (custom必填) + - audio: 音频链接 (custom必填) + - title: 标题 (custom必填) + - image: 封面图片 (可选) + - singer: 歌手 (可选) + """ + data = { + "group_id": group_id, + "music_type": music_type, + **kwargs + } + return self._request("send_group_music", data) + + def send_private_music(self, user_id: int, music_type: str, **kwargs) -> dict: + """发送私聊音乐卡片""" + data = { + "user_id": user_id, + "music_type": music_type, + **kwargs + } + return self._request("send_private_music", data) + + +# ============================================================================ +# 测试用例 +# ============================================================================ + +def test_163_music(client: YogurtClient, group_id: int): + """测试网易云音乐""" + print("\n" + "=" * 60) + print("🎵 测试: 网易云音乐 (163)") + print("=" * 60) + + # 使用你提供的歌曲 ID + result = client.send_group_music( + group_id=group_id, + music_type="163", + id="1999253939" # 网易云歌曲ID + ) + return result + + +def test_qq_music(client: YogurtClient, group_id: int): + """测试QQ音乐""" + print("\n" + "=" * 60) + print("🎵 测试: QQ音乐") + print("=" * 60) + + result = client.send_group_music( + group_id=group_id, + music_type="qq", + id="384227436" # QQ音乐歌曲ID + ) + return result + + +def test_custom_music(client: YogurtClient, group_id: int): + """测试自定义音乐卡片""" + print("\n" + "=" * 60) + print("🎵 测试: 自定义音乐卡片") + print("=" * 60) + + result = client.send_group_music( + group_id=group_id, + music_type="custom", + url="https://music.163.com/#/song?id=1999253939", + audio="https://music.163.com/song/media/outer/url?id=1999253939.mp3", + title="测试自定义音乐", + image="https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg", + singer="测试歌手" + ) + return result + + +def test_kugou_music(client: YogurtClient, group_id: int): + """测试酷狗音乐""" + print("\n" + "=" * 60) + print("🎵 测试: 酷狗音乐") + print("=" * 60) + + # 酷狗音乐需要 hash 作为 ID + result = client.send_group_music( + group_id=group_id, + music_type="kugou", + id="1571941423D8D7E290E5DD7655E8A7C7" # 酷狗音乐 hash + ) + return result + + +# ============================================================================ +# 私聊测试用例 +# ============================================================================ + +def test_private_163_music(client: YogurtClient, user_id: int): + """测试私聊网易云音乐""" + print("\n" + "=" * 60) + print("🎵 私聊测试: 网易云音乐 (163)") + print("=" * 60) + + result = client.send_private_music( + user_id=user_id, + music_type="163", + id="1999253939" + ) + return result + + +def test_private_custom_music(client: YogurtClient, user_id: int): + """测试私聊自定义音乐卡片""" + print("\n" + "=" * 60) + print("🎵 私聊测试: 自定义音乐卡片") + print("=" * 60) + + result = client.send_private_music( + user_id=user_id, + music_type="custom", + url="https://music.163.com/#/song?id=1999253939", + audio="https://music.163.com/song/media/outer/url?id=1999253939.mp3", + title="私聊测试音乐", + image="https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg", + singer="测试歌手" + ) + return result + + +# ============================================================================ +# 主函数 +# ============================================================================ + +def main(): + print(""" +╔══════════════════════════════════════════════════════════════╗ +║ 🍦 Yogurt 音乐卡片测试脚本 ║ +║ ║ +║ 配置: ║ +║ - 服务地址: {host}:{port} ║ +║ - 访问令牌: {token} ║ +║ - 测试群号: {group} ║ +║ - 测试私聊: {user} ║ +╚══════════════════════════════════════════════════════════════╝ +""".format( + host=YOGURT_HOST, + port=YOGURT_PORT, + token=ACCESS_TOKEN, + group=GROUP_ID, + user=USER_ID + )) + + # 创建客户端 + client = YogurtClient(YOGURT_HOST, YOGURT_PORT, ACCESS_TOKEN) + + # 选择测试 + print("请选择要测试的音乐类型:") + print(" === 群聊测试 ===") + print(" 1. 网易云音乐 (163)") + print(" 2. QQ音乐") + print(" 3. 自定义音乐卡片") + print(" 4. 酷狗音乐") + print(" 5. 全部群聊测试") + print() + print(" === 私聊测试 ===") + print(" 6. [私聊] 网易云音乐 (163)") + print(" 7. [私聊] 自定义音乐卡片") + print(" 8. [私聊] 全部测试") + print() + print(" 0. 退出") + + while True: + try: + choice = input("\n请输入选项 (0-9): ").strip() + + if choice == "0": + print("👋 再见!") + break + elif choice == "1": + test_163_music(client, GROUP_ID) + elif choice == "2": + test_qq_music(client, GROUP_ID) + elif choice == "3": + test_custom_music(client, GROUP_ID) + elif choice == "4": + test_kugou_music(client, GROUP_ID) + elif choice == "5": + test_163_music(client, GROUP_ID) + test_qq_music(client, GROUP_ID) + test_custom_music(client, GROUP_ID) + elif choice == "6": + test_private_163_music(client, USER_ID) + elif choice == "7": + test_private_custom_music(client, USER_ID) + elif choice == "8": + test_private_163_music(client, USER_ID) + test_private_custom_music(client, USER_ID) + else: + print("❌ 无效选项,请重新输入") + except KeyboardInterrupt: + print("\n👋 再见!") + break + + +if __name__ == "__main__": + main() diff --git a/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/YogurtConfig.kt b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/YogurtConfig.kt index 9c9a3ae..4db3550 100644 --- a/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/YogurtConfig.kt +++ b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/YogurtConfig.kt @@ -16,6 +16,8 @@ import org.ntqqrev.acidify.logging.LogLevel @Serializable class YogurtConfig( val signApiUrl: String = "", + /** 音乐卡片签名服务器 URL,留空使用默认值 (https://ss.xingzhige.com/music_card/card) */ + val musicSignUrl: String = "", val reportSelfMessage: Boolean = true, val preloadContacts: Boolean = false, val transformIncomingMFaceToImage: Boolean = false, diff --git a/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/HttpRoutes.kt b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/HttpRoutes.kt index 9d29699..93306ca 100644 --- a/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/HttpRoutes.kt +++ b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/HttpRoutes.kt @@ -163,4 +163,8 @@ fun Route.configureMilkyApiHttpRoutes() { serve(CreateGroupFolder) serve(RenameGroupFolder) serve(DeleteGroupFolder) + + // 音乐卡片扩展 API + sendGroupMusicRoute() + sendPrivateMusicRoute() } \ No newline at end of file diff --git a/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/message/SendMusicMessage.kt b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/message/SendMusicMessage.kt new file mode 100644 index 0000000..6c40a08 --- /dev/null +++ b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/api/message/SendMusicMessage.kt @@ -0,0 +1,298 @@ +package org.ntqqrev.yogurt.api.message + +import io.ktor.http.* +import io.ktor.server.plugins.di.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.ntqqrev.acidify.Bot +import org.ntqqrev.yogurt.music.MusicSignException +import org.ntqqrev.yogurt.music.MusicSignRequest +import org.ntqqrev.yogurt.music.MusicSigner +import org.ntqqrev.yogurt.music.MusicType + +/** + * 发送群聊音乐卡片 API + * + * 端点: POST /send_group_music + */ +fun Route.sendGroupMusicRoute() { + post("/send_group_music") { + val bot = application.dependencies.resolve() + val input = call.receive() + + // 检查群聊是否存在 + bot.getGroup(input.groupId) + ?: return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -404, + message = "Group not found" + ) + ) + + try { + // 验证输入 + val musicType = try { + MusicType.fromString(input.musicType) + } catch (e: IllegalArgumentException) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -400, + message = "Invalid music type: ${input.musicType}" + ) + ) + } + + // 对于自定义音乐,验证必填字段 + if (musicType == MusicType.CUSTOM) { + if (input.url.isNullOrBlank() || input.audio.isNullOrBlank() || input.title.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -400, + message = "Custom music requires url, audio, and title fields" + ) + ) + } + } else { + // 对于平台音乐,验证 ID + if (input.id.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -400, + message = "Platform music requires id field" + ) + ) + } + } + + // 签名音乐卡片 + val signer = MusicSigner() + val signRequest = MusicSignRequest( + type = input.musicType, + id = input.id, + url = input.url, + audio = input.audio, + title = input.title, + image = input.image, + singer = input.singer + ) + + val arkPayload = try { + signer.sign(signRequest) + } catch (e: MusicSignException) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -500, + message = "Music sign failed: ${e.message}" + ) + ) + } finally { + signer.close() + } + + // 发送消息 + val result = bot.sendGroupMessage(input.groupId) { + lightApp(arkPayload) + } + + call.respond( + HttpStatusCode.OK, + MusicSuccessResponse( + data = SendMusicOutput( + messageSeq = result.sequence, + time = result.sendTime + ) + ) + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -500, + message = "Internal error: ${e.message}" + ) + ) + } + } +} + +/** + * 发送私聊音乐卡片 API + * + * 端点: POST /send_private_music + */ +fun Route.sendPrivateMusicRoute() { + post("/send_private_music") { + val bot = application.dependencies.resolve() + val input = call.receive() + + // 检查好友是否存在 + bot.getFriend(input.userId) + ?: return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -404, + message = "Friend not found" + ) + ) + + try { + // 验证输入 + val musicType = try { + MusicType.fromString(input.musicType) + } catch (e: IllegalArgumentException) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -400, + message = "Invalid music type: ${input.musicType}" + ) + ) + } + + // 对于自定义音乐,验证必填字段 + if (musicType == MusicType.CUSTOM) { + if (input.url.isNullOrBlank() || input.audio.isNullOrBlank() || input.title.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -400, + message = "Custom music requires url, audio, and title fields" + ) + ) + } + } else { + // 对于平台音乐,验证 ID + if (input.id.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -400, + message = "Platform music requires id field" + ) + ) + } + } + + // 签名音乐卡片 + val signer = MusicSigner() + val signRequest = MusicSignRequest( + type = input.musicType, + id = input.id, + url = input.url, + audio = input.audio, + title = input.title, + image = input.image, + singer = input.singer + ) + + val arkPayload = try { + signer.sign(signRequest) + } catch (e: MusicSignException) { + return@post call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -500, + message = "Music sign failed: ${e.message}" + ) + ) + } finally { + signer.close() + } + + // 发送消息 + val result = bot.sendFriendMessage(input.userId) { + lightApp(arkPayload) + } + + call.respond( + HttpStatusCode.OK, + MusicSuccessResponse( + data = SendMusicOutput( + messageSeq = result.sequence, + time = result.sendTime + ) + ) + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.OK, + MusicErrorResponse( + retcode = -500, + message = "Internal error: ${e.message}" + ) + ) + } + } +} + +@Serializable +data class SendGroupMusicInput( + /** 群号 */ + @SerialName("group_id") val groupId: Long, + /** 音乐类型: qq, 163, kugou, migu, kuwo, custom */ + @SerialName("music_type") val musicType: String, + /** 音乐 ID(平台音乐必填) */ + @SerialName("id") val id: String? = null, + /** 跳转 URL(自定义音乐必填) */ + @SerialName("url") val url: String? = null, + /** 音频 URL(自定义音乐必填) */ + @SerialName("audio") val audio: String? = null, + /** 标题(自定义音乐必填) */ + @SerialName("title") val title: String? = null, + /** 封面图片 URL(可选) */ + @SerialName("image") val image: String? = null, + /** 歌手名称(可选) */ + @SerialName("singer") val singer: String? = null, +) + +@Serializable +data class SendPrivateMusicInput( + /** 好友 QQ 号 */ + @SerialName("user_id") val userId: Long, + /** 音乐类型: qq, 163, kugou, migu, kuwo, custom */ + @SerialName("music_type") val musicType: String, + /** 音乐 ID(平台音乐必填) */ + @SerialName("id") val id: String? = null, + /** 跳转 URL(自定义音乐必填) */ + @SerialName("url") val url: String? = null, + /** 音频 URL(自定义音乐必填) */ + @SerialName("audio") val audio: String? = null, + /** 标题(自定义音乐必填) */ + @SerialName("title") val title: String? = null, + /** 封面图片 URL(可选) */ + @SerialName("image") val image: String? = null, + /** 歌手名称(可选) */ + @SerialName("singer") val singer: String? = null, +) + +@Serializable +data class SendMusicOutput( + /** 消息序列号 */ + @SerialName("message_seq") val messageSeq: Long, + /** 消息发送时间 */ + @SerialName("time") val time: Long, +) + +/** 错误响应(无 data 字段) */ +@Serializable +data class MusicErrorResponse( + val status: String = "failed", + val retcode: Int, + val message: String, +) + +/** 成功响应(带 data 字段) */ +@Serializable +data class MusicSuccessResponse( + val status: String = "ok", + val retcode: Int = 0, + val data: SendMusicOutput, +) diff --git a/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/music/MusicSegment.kt b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/music/MusicSegment.kt new file mode 100644 index 0000000..53d9b70 --- /dev/null +++ b/yogurt/src/commonMain/kotlin/org/ntqqrev/yogurt/music/MusicSegment.kt @@ -0,0 +1,165 @@ +package org.ntqqrev.yogurt.music + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* + +/** + * 音乐平台类型 + */ +enum class MusicType(val value: String) { + QQ("qq"), + NETEASE("163"), + KUGOU("kugou"), + MIGU("migu"), + KUWO("kuwo"), + CUSTOM("custom"); + + companion object { + fun fromString(value: String): MusicType { + return entries.find { it.value == value } + ?: throw IllegalArgumentException("Unknown music type: $value") + } + } +} + +/** + * 音乐消息段 - Milky 协议扩展 + * + * 用于发送音乐卡片消息,支持 QQ音乐、网易云、酷狗等平台 + */ +@Serializable +data class MusicSegment( + /** 音乐类型: qq, 163, kugou, migu, kuwo, custom */ + @SerialName("type") val type: String, + /** 音乐段数据 */ + @SerialName("data") val data: MusicData +) + +@Serializable +data class MusicData( + /** 音乐类型 */ + @SerialName("music_type") val musicType: String, + /** 音乐 ID(平台音乐必填) */ + @SerialName("id") val id: String? = null, + /** 跳转 URL(自定义音乐必填) */ + @SerialName("url") val url: String? = null, + /** 音频 URL(自定义音乐必填) */ + @SerialName("audio") val audio: String? = null, + /** 标题(自定义音乐必填) */ + @SerialName("title") val title: String? = null, + /** 封面图片 URL(自定义音乐可选) */ + @SerialName("image") val image: String? = null, + /** 歌手名称(自定义音乐可选) */ + @SerialName("singer") val singer: String? = null, +) + +/** + * 音乐签名请求 + */ +@Serializable +data class MusicSignRequest( + /** 音乐类型 */ + @SerialName("type") val type: String, + /** 音乐 ID */ + @SerialName("id") val id: String? = null, + /** 跳转 URL */ + @SerialName("url") val url: String? = null, + /** 音频 URL */ + @SerialName("audio") val audio: String? = null, + /** 标题 */ + @SerialName("title") val title: String? = null, + /** 封面图片 URL */ + @SerialName("image") val image: String? = null, + /** 歌手名称 */ + @SerialName("singer") val singer: String? = null, +) + +/** + * 音乐签名响应 + */ +@Serializable +data class MusicSignResponse( + /** 签名后的 JSON Ark Payload */ + @SerialName("data") val data: String? = null, + /** 错误信息 */ + @SerialName("error") val error: String? = null, + /** 状态码 */ + @SerialName("code") val code: Int = 0, +) + +/** + * 音乐签名异常 + */ +class MusicSignException(message: String) : Exception(message) + +/** + * 音乐签名器 - 调用外部签名服务获取 Ark JSON + * + * 签名服务地址: https://ss.xingzhige.com/music_card/card + * 该服务直接返回原始 Ark JSON (不是包装的响应对象) + */ +class MusicSigner( + private val signUrl: String = "https://ss.xingzhige.com/music_card/card" +) { + private val client = HttpClient() + private val json = Json { ignoreUnknownKeys = true } + + /** + * 签名音乐请求,返回 Ark JSON 字符串 + * + * @param request 音乐签名请求 + * @return Ark JSON 字符串 + * @throws MusicSignException 签名失败时抛出 + */ + suspend fun sign(request: MusicSignRequest): String { + try { + // 构建请求体 + val requestBody = buildJsonObject { + put("type", request.type) + request.id?.let { put("id", it) } + request.url?.let { put("url", it) } + request.audio?.let { put("audio", it) } + request.title?.let { put("title", it) } + request.image?.let { put("image", it) } + request.singer?.let { put("singer", it) } + } + + val response = client.post(signUrl) { + contentType(ContentType.Application.Json) + setBody(requestBody.toString()) + } + + if (!response.status.isSuccess()) { + throw MusicSignException("Sign service returned ${response.status}") + } + + // 签名服务直接返回 Ark JSON + val responseText = response.bodyAsText() + + // 验证响应是否为有效的 Ark JSON (应该包含 "app" 字段) + try { + val jsonElement = json.parseToJsonElement(responseText) + if (jsonElement is JsonObject && jsonElement.containsKey("app")) { + return responseText + } else { + throw MusicSignException("Invalid response: not an Ark message") + } + } catch (e: Exception) { + if (e is MusicSignException) throw e + throw MusicSignException("Failed to parse response: ${e.message}") + } + } catch (e: Exception) { + if (e is MusicSignException) throw e + throw MusicSignException("Sign request failed: ${e.message}") + } + } + + fun close() { + client.close() + } +}