diff --git a/utils/tools/SendMusicTool.js b/utils/tools/SendMusicTool.js index cb0bc61..6687eba 100644 --- a/utils/tools/SendMusicTool.js +++ b/utils/tools/SendMusicTool.js @@ -1,4 +1,5 @@ import { AbstractTool } from './AbstractTool.js' +import https from 'https' export class SendMusicTool extends AbstractTool { name = 'sendMusic' @@ -17,6 +18,61 @@ export class SendMusicTool extends AbstractTool { required: ['id'] } + // 获取歌曲详情 + getSongDetail(id) { + return new Promise((resolve) => { + const options = { + hostname: 'music.163.com', + path: `/api/song/detail/?id=${id}&ids=[${id}]`, + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Referer': 'https://music.163.com/', + 'Origin': 'https://music.163.com' + } + } + + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + res.on('end', () => { + try { + const result = JSON.parse(data) + if (result.code === 200 && result.songs && result.songs[0]) { + const song = result.songs[0] + resolve({ + name: song.name, + artist: song.artists[0].name, + album: song.album.name, + picUrl: song.album.picUrl, + duration: Math.floor(song.duration / 1000), // 转换为秒 + quality: { + sq: song.sqMusic ? '无损' : null, + hq: song.hMusic ? '高品质' : null, + lq: song.lMusic ? '标准' : null + } + }) + } else { + resolve(null) + } + } catch (e) { + console.error('解析歌曲详情失败:', e) + resolve(null) + } + }) + }) + + req.on('error', (e) => { + console.error('获取歌曲详情失败:', e) + resolve(null) + }) + + req.end() + }) + } + func = async function (opts, e) { let { id, targetGroupIdOrQQNumber } = opts // 非法值则发送到当前群聊 @@ -29,16 +85,35 @@ export class SendMusicTool extends AbstractTool { let group = await e.bot.pickGroup(target) // 检查是否支持 shareMusic 方法 - if (typeof group.shareMusic === 'function') { + if (typeof group.shareMusic === 'function' && e.adapter_name === 'icqq') { await group.shareMusic('163', id) } else { + // 获取歌曲详情 + const songDetail = await this.getSongDetail(id) + // 构建音乐分享消息 - const musicMsg = { - type: 'music', - data: { - type: '163', - id: id, - jumpUrl: `https://music.163.com/#/song?id=${id}` + let musicMsg + if (e.adapter_name === 'OneBotv11') { + // 适配onebotv11协议 + musicMsg = [{ + type: 'music', + data: { + type: 'custom', + url: `https://music.163.com/#/song?id=${id}`, + audio: `http://music.163.com/song/media/outer/url?id=${id}.mp3`, + title: songDetail ? `${songDetail.name} - ${songDetail.artist}` : '网易云音乐', + image: songDetail?.picUrl || 'https://p1.music.126.net/tBTNafgjNnTL1KlZMt7lVA==/18885211718935735.jpg' + } + }] + } else { + // 原有格式 + musicMsg = { + type: 'music', + data: { + type: '163', + id: id, + jumpUrl: `https://music.163.com/#/song?id=${id}` + } } } await e.reply(musicMsg) @@ -50,4 +125,4 @@ export class SendMusicTool extends AbstractTool { } description = 'Useful when you want to share music. You must use searchMusic first to get the music id. If no extra description needed, just reply at the next turn' -} +} \ No newline at end of file diff --git a/utils/tools/SendPictureTool.js b/utils/tools/SendPictureTool.js index 412bc2c..87714f3 100644 --- a/utils/tools/SendPictureTool.js +++ b/utils/tools/SendPictureTool.js @@ -5,6 +5,37 @@ import {Config} from '../config.js' export class SendPictureTool extends AbstractTool { name = 'sendPicture' + // 解析 CQ 码的方法 + parseCQCode(message) { + const cqRegex = /\[CQ:([^,]+)(?:,([^\]]+))?\]/g + const result = [] + let match + + while ((match = cqRegex.exec(message)) !== null) { + const type = match[1] + const params = {} + + if (match[2]) { + match[2].split(',').forEach(param => { + const [key, ...values] = param.split('=') + if (key && values.length) { + params[key] = values.join('=') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + } + }) + } + + if (type === 'image') { + result.push(params.url || params.file) + } + } + + return result + } + parameters = { properties: { urlOfPicture: { @@ -21,64 +52,262 @@ export class SendPictureTool extends AbstractTool { func = async function (opt, e) { let { urlOfPicture, targetGroupIdOrQQNumber, sender } = opt - if (typeof urlOfPicture === 'object') { - urlOfPicture = urlOfPicture.join(' ') + + // 处理数组格式的消息体 + if (typeof urlOfPicture === 'string') { + try { + // 尝试解析为 JSON + const msgArray = JSON.parse(urlOfPicture) + if (Array.isArray(msgArray)) { + // 处理消息数组 + const messages = [] + for (const msg of msgArray) { + if (typeof msg === 'object' && msg.type === 'text' && msg.data?.text) { + if (msg.data.text.includes('[CQ:image')) { + // 提取 CQ 码中的参数 + const cqMatch = msg.data.text.match(/\[CQ:image,([^\]]+)\]/) + if (cqMatch) { + const params = {} + cqMatch[1].split(',').forEach(param => { + const [key, ...values] = param.split('=') + if (key && values.length) { + params[key] = values.join('=') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + } + }) + // 创建正确的图片消息格式 + messages.push({ + type: 'image', + data: { + file: params.url || params.file, + ...params + } + }) + } + } else { + // 保留普通文本消息 + messages.push(msg) + } + } else { + messages.push(msg) + } + } + // 只保留图片消息 + const imageMsg = messages.find(msg => msg.type === 'image') + if (imageMsg) { + urlOfPicture = imageMsg.data.file + } else { + return 'No valid image found in the message' + } + } + } catch (err) { + // 如果不是JSON格式,尝试解析 CQ 码 + if (urlOfPicture.includes('[CQ:image')) { + const cqMatch = urlOfPicture.match(/\[CQ:image,([^\]]+)\]/) + if (cqMatch) { + const params = {} + cqMatch[1].split(',').forEach(param => { + const [key, ...values] = param.split('=') + if (key && values.length) { + params[key] = values.join('=') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + } + }) + urlOfPicture = params.url || params.file + } + } + } + } else if (typeof urlOfPicture === 'object') { + if (Array.isArray(urlOfPicture)) { + // 处理消息数组 + const messages = [] + for (const msg of urlOfPicture) { + if (typeof msg === 'object' && msg.type === 'text' && msg.data?.text) { + if (msg.data.text.includes('[CQ:image')) { + // 提取 CQ 码中的参数 + const cqMatch = msg.data.text.match(/\[CQ:image,([^\]]+)\]/) + if (cqMatch) { + const params = {} + cqMatch[1].split(',').forEach(param => { + const [key, ...values] = param.split('=') + if (key && values.length) { + params[key] = values.join('=') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + } + }) + // 创建正确的图片消息格式 + messages.push({ + type: 'image', + data: { + file: params.url || params.file, + ...params + } + }) + } + } else { + // 保留普通文本消息 + messages.push(msg) + } + } else { + messages.push(msg) + } + } + // 只保留图片消息 + const imageMsg = messages.find(msg => msg.type === 'image') + if (imageMsg) { + urlOfPicture = imageMsg.data.file + } else { + return 'No valid image found in the message' + } + } else { + urlOfPicture = urlOfPicture.join(' ') + } } + const defaultTarget = e.isGroup ? e.group_id : e.sender.user_id const target = isNaN(targetGroupIdOrQQNumber) || !targetGroupIdOrQQNumber ? defaultTarget : parseInt(targetGroupIdOrQQNumber) === e.bot.uin ? defaultTarget : parseInt(targetGroupIdOrQQNumber) + // 处理错误url和picture留空的情况 const urlRegex = /(?:(?:https?|ftp):\/\/)?(?:\S+(?::\S*)?@)?(?:((?:(?:[a-z0-9\u00a1-\u4dff\u9fd0-\uffff][a-z0-9\u00a1-\u4dff\u9fd0-\uffff_-]{0,62})?[a-z0-9\u00a1-\u4dff\u9fd0-\uffff]\.)+(?:[a-z\u00a1-\u4dff\u9fd0-\uffff]{2,}\.?))(?::\d{2,5})?)(?:\/[\w\u00a1-\u4dff\u9fd0-\uffff$-_.+!*'(),%]+)*(?:\?(?:[\w\u00a1-\u4dff\u9fd0-\uffff$-_.+!*(),%:@&=]|(?:[\[\]])|(?:[\u00a1-\u4dff\u9fd0-\uffff]))*)?(?:#(?:[\w\u00a1-\u4dff\u9fd0-\uffff$-_.+!*'(),;:@&=]|(?:[\[\]]))*)?\/?/i - if (/https:\/\/example.com/.test(urlOfPicture) || !urlOfPicture || !urlRegex.test(urlOfPicture)) urlOfPicture = '' + if (!urlOfPicture) { - return 'Because there is no correct URL for the picture ,tell user the reason and ask user if he want to use SearchImageTool' + return 'No picture URL provided' } - let pictures = urlOfPicture.trim().split(' ') - logger.mark('pictures to send: ', pictures) - pictures = pictures.map(img => segment.image(img)) - let groupList - try { - groupList = await e.bot.getGroupList() - } catch (err) { - groupList = e.bot.gl + + // 处理多个URL的情况 + let urls = urlOfPicture.split(/\s+/).filter(url => { + return urlRegex.test(url) + }) + + if (urls.length === 0) { + return 'No valid picture URLs found' } + + logger.mark('pictures to send: ', urls) + let pictures = urls.map(img => { + // 检测适配器类型 + const isICQQ = !!(e.bot?.adapter?.name === 'icqq') + + if (img.startsWith('http')) { + if (isICQQ) { + // ICQQ适配器使用segment + return segment.image(img) + } else { + // OneBotv11适配器使用对象格式 + return { + type: 'image', + data: { + file: img, + cache: false, // 禁用缓存 + proxy: false, // 禁用代理 + timeout: 60000, // 60秒超时 + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + } + } + } + } + return segment.image(img) + }) + let errs = [] try { - if ((typeof groupList.get === 'function' && groupList.get(target)) || - (Array.isArray(groupList) && groupList.includes(target))) { + // 获取群列表 + let groupList = await e.bot?.getGroupList?.() || e.bot.gl || [] + + // 发送到群 + if (target.toString().length >= 6) { // 群号一般大于6位 let group = await e.bot.pickGroup(target) + if (!group) { + return `Failed to find group: ${target}` + } + for (let pic of pictures) { - try { - await group.sendMsg(pic) - } catch (err) { - errs.push(pic) + let retryCount = 0 + const maxRetries = 3 + let lastError = null + + while (retryCount < maxRetries) { + try { + // 根据适配器类型选择发送方式 + const isICQQ = !!(e.bot?.adapter?.name === 'icqq') + const msgToSend = isICQQ ? pic : [pic] + + // 设置超时Promise + const sendPromise = group.sendMsg(msgToSend) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Send timeout')), 30000) + }) + + await Promise.race([sendPromise, timeoutPromise]) + logger.mark(`图片发送成功: ${typeof pic === 'string' ? pic : pic.data?.file}`) + break + } catch (err) { + lastError = err + retryCount++ + const isTimeout = err.message?.includes('ETIMEDOUT') || err.message?.includes('timeout') + logger.error(`发送图片失败 (尝试 ${retryCount}/${maxRetries}): ${isTimeout ? '连接超时' : err.message}`) + + if (retryCount === maxRetries) { + errs.push(typeof pic === 'string' ? pic : (pic.data?.file || pic.url || '未知图片')) + logger.error('图片发送最终失败:', lastError.message || lastError) + } else { + // 根据错误类型调整等待时间 + const waitTime = isTimeout ? 5000 * retryCount : 3000 * retryCount + await new Promise(resolve => setTimeout(resolve, waitTime)) + } + } } } - // await group.sendMsg(pictures) - return 'picture has been sent to group' + target + (errs.length > 0 ? `, but some pictures failed to send (${errs.join('、')})` : '') - } else { - let masters = (await getMasterQQ()) - if (!Config.enableToolPrivateSend && !masters.includes(sender + '')) { - return 'you are not allowed to pm other group members' + + if (errs.length > 0) { + return `部分图片发送失败 (${errs.length}/${pictures.length}): ${errs.join(', ')}` } + return true + } + // 发送到私聊 + else { + let masters = await getMasterQQ() + if (!Config.enableToolPrivateSend && !masters.includes(sender.toString())) { + return 'You are not allowed to send private messages' + } + let user = e.bot.pickUser(target) - if (e.group_id) { - user = user.asMember(e.group_id) - } for (let pic of pictures) { try { - await user.sendMsg(pic) + // 根据适配器类型选择发送方式 + const isICQQ = !!(e.bot?.adapter?.name === 'icqq') + const msgToSend = isICQQ ? pic : [pic] + + await user.sendMsg(msgToSend) + await new Promise(resolve => setTimeout(resolve, 1000)) } catch (err) { - errs.push(pic.url) + logger.error('发送图片失败:', err) + errs.push(typeof pic === 'string' ? pic : (pic.data?.file || pic.url || '未知图片')) } } - return 'picture has been sent to user' + target + (errs.length > 0 ? `, but some pictures failed to send (${errs.join('、')})` : '') + return `Pictures have been sent to user ${target}${errs.length > 0 ? `, but ${errs.length} pictures failed to send` : ''}` } } catch (err) { - return `failed to send pictures, error: ${JSON.stringify(err)}` + logger.error('发送图片过程出错:', err) + return `Failed to send pictures: ${err.message || JSON.stringify(err)}` } } - description = 'Useful when you want to send one or more pictures. If no extra description needed, just reply at the next turn' + description = 'Useful when you want to send one or more pictures. If no extra description needed, just reply at the next turn' }