Skip to content

哒哒#1

Closed
NanoRocky wants to merge 2 commits into
chuyegzs:mainfrom
NanoRocky:main
Closed

哒哒#1
NanoRocky wants to merge 2 commits into
chuyegzs:mainfrom
NanoRocky:main

Conversation

@NanoRocky
Copy link
Copy Markdown
Collaborator

Warning

提醒

     · 该版本移除了对 酷我音乐 和 酷狗音乐 的支持。

⚙️ 更改

     1. 增加对 PHP 版本 MetingAPI 的支持,可通过 api_type 切换。
     2. 增加对QQ 音乐卡片的支持,通过 use_music_card 开启。因 AstrBox 有 BUG ,暂无法发送 JSON 消息,需要等待 5208 修复后卡片功能才能正常工作。代码内已添加检测,在不支持的版本时会在控制台输出警告。
     3. 增加可用指令。
     4. BUG 修复,增加错误处理。
     ...

Copilot AI review requested due to automatic review settings February 18, 2026 15:39
@NanoRocky NanoRocky closed this Feb 18, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request updates the MetingAPI music plugin to version 1.2.2, removing support for Kugou and Kuwo music sources while adding support for QQ Music cards and PHP-based MetingAPI. The changes significantly simplify the codebase by removing extensive error handling, URL validation, and resource management logic, though this introduces several critical bugs and security vulnerabilities.

Changes:

  • Added support for PHP-based MetingAPI (configurable via api_type)
  • Added QQ Music card feature using JSON message components with signature API
  • Removed support for Kugou and Kuwo music sources
  • Simplified download and audio processing logic, removing most error handling and security checks
  • Added new command aliases for source switching and direct platform-specific search commands

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 23 comments.

File Description
main.py Major refactoring: Added music card support, removed security validations (SSRF protection), simplified error handling, removed resource cleanup mechanisms, and added new command handlers
_conf_schema.json Added configuration options for api_type, use_music_card, and api_sign_url; removed Kugou and Kuwo from hints

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.py
Comment on lines 136 to 142
async def _get_session(self, session_id: str) -> SessionData:
"""获取会话状态(线程安全)

Args:
session_id: 会话 ID

Returns:
SessionData: 会话状态对象
"""
await self._ensure_initialized()
async with self._sessions_lock:
if session_id not in self._sessions:
self._sessions[session_id] = SessionData(self.get_default_source())
default_source = str(self._get_config("default_source", "netease"))
self._sessions[session_id] = SessionData(default_source)
return self._sessions[session_id]
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session cleanup logic has been removed. The old code had periodic cleanup of old sessions (older than MAX_SESSION_AGE) to prevent memory leaks from abandoned sessions. The new code removes this cleanup task entirely, meaning sessions will accumulate in memory indefinitely.

Re-implement periodic session cleanup or add a mechanism to remove stale sessions to prevent memory leaks in long-running instances.

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines +117 to +120
def _get_config(self, key: str, default=None):
if not self.config:
return default

value = self.config.get(key, default)
if validator is not None and not validator(value):
return default

return value
return self.config.get(key, default)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _get_config method no longer performs any validation on configuration values. The old code had a validator parameter to check if values were within acceptable ranges and types. Now any value from the config is used directly, which could cause runtime errors if users provide invalid values.

For example, search_result_count could be negative or non-integer, segment_duration could be 0 or negative, etc. Add validation to ensure configuration values are within acceptable ranges and types.

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines +108 to +113
with open(stage.__file__, "r", encoding="utf-8") as f:
content = f.read()
if "Comp.Json" not in content:
logger.warning(
"检测到当前 AstrBot 版本可能不支持 JSON 消息组件。请更新 AstrBot 版本,否则音乐卡片可能无法发送。"
)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version compatibility check is fragile and could produce false positives. Reading the stage module's source file and searching for "Comp.Json" as a string is unreliable because:

  1. The string search could match in comments or string literals
  2. Module refactoring could change the implementation without changing functionality
  3. Reading source files directly is not a standard way to check API compatibility

Consider using hasattr() to check if the required functionality exists at runtime, or comparing version numbers if the framework provides version information.

Suggested change
with open(stage.__file__, "r", encoding="utf-8") as f:
content = f.read()
if "Comp.Json" not in content:
logger.warning(
"检测到当前 AstrBot 版本可能不支持 JSON 消息组件。请更新 AstrBot 版本,否则音乐卡片可能无法发送。"
)
comp = getattr(stage, "Comp", None)
has_json_support = comp is not None and hasattr(comp, "Json")
if not has_json_support:
logger.warning(
"检测到当前 AstrBot 版本可能不支持 JSON 消息组件。请更新 AstrBot 版本,否则音乐卡片可能无法发送。"
)

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines +323 to +355
async def _download_song(self, url: str) -> str | None:
temp_path = get_astrbot_temp_path()
if not os.path.exists(temp_path):
os.makedirs(temp_path)

Args:
url: 歌曲 URL
sender_id: 发送者 ID
temp_file = os.path.join(temp_path, f"{TEMP_FILE_PREFIX}{uuid.uuid4()}.tmp")

Returns:
str | None: 临时文件路径,失败返回 None
"""
if not self._http_session:
raise DownloadError("HTTP session 未初始化")

temp_dir = tempfile.gettempdir()
safe_sender_id = "".join(c for c in str(sender_id) if c.isalnum() or c in "._-")

download_success = False
max_retries = 3
retry_count = 0
temp_file = None
detected_format = None

while retry_count < max_retries:
try:
async with self._download_semaphore:
logger.debug(
f"开始下载歌曲 (尝试 {retry_count + 1}/{max_retries}): {url}"
)

current_url = url
redirect_count = 0
max_redirects = 5

while redirect_count < max_redirects:
is_valid, reason = await self._validate_url(current_url)
if not is_valid:
raise UnsafeURLError(f"URL 验证失败: {reason}")

async with self._http_session.get(
current_url, allow_redirects=False
) as resp:
if resp.status in (301, 302, 307, 308):
redirect_url = resp.headers.get("Location", "")
if not redirect_url:
raise DownloadError("重定向响应缺少 Location 头")

current_url = urljoin(current_url, redirect_url)
logger.debug(f"跟随重定向: {current_url}")
redirect_count += 1
continue

if resp.status != 200:
raise DownloadError(f"下载失败,状态码: {resp.status}")

content_type = resp.headers.get("Content-Type", "")
if not self._is_audio_content(content_type):
raise AudioFormatError(
f"不支持的 Content-Type: {content_type}"
)

max_file_size = self.get_max_file_size()
total_size = 0
first_chunk = None
temp_file = os.path.join(
temp_dir,
f"{TEMP_FILE_PREFIX}{safe_sender_id}_{uuid.uuid4()}.tmp",
)

with open(temp_file, "wb") as f:
try:
async for chunk in resp.content.iter_chunked(
CHUNK_SIZE
):
if first_chunk is None and chunk:
first_chunk = chunk
detected_format = _detect_audio_format(
first_chunk
)
if not detected_format:
raise AudioFormatError(
"文件头检测失败,不是有效的音频文件"
)

f.write(chunk)
total_size += len(chunk)
if total_size > max_file_size:
raise DownloadError(
f"文件过大,已超过 {max_file_size} 字节"
)
except aiohttp.ClientPayloadError as e:
raise DownloadError(f"连接中断: {e}") from e

file_size = os.path.getsize(temp_file)
if file_size == 0:
raise DownloadError("下载的文件为空")

file_ext = _get_extension_from_format(detected_format)
final_file = temp_file + file_ext
os.rename(temp_file, final_file)
temp_file = final_file

logger.info(
f"歌曲下载成功,文件大小: {file_size} 字节,格式: {detected_format}"
)
download_success = True
return temp_file

raise DownloadError(f"重定向次数超过限制: {max_redirects}")

except (aiohttp.ClientError, aiohttp.ClientPayloadError) as e:
retry_count += 1
logger.error(
f"下载歌曲时网络错误 (尝试 {retry_count}/{max_retries}): {e}"
async with self._download_semaphore:
if not self._http_session:
return None
async with self._http_session.get(url, allow_redirects=True) as resp:
if resp.status != 200:
return None
content = await resp.read()

detected_format = _detect_audio_format(content[:1024])
ext_map = {
"mp3": ".mp3",
"wav": ".wav",
"ogg": ".ogg",
"flac": ".flac",
"mp4": ".m4a",
}
ext = (
ext_map.get(str(detected_format), ".mp3")
if detected_format
else ".mp3"
)
if retry_count >= max_retries:
raise DownloadError(f"网络错误: {e}") from e
await asyncio.sleep(1)
except (DownloadError, UnsafeURLError, AudioFormatError):
raise
except Exception as e:
logger.error(f"下载歌曲时发生错误: {e}", exc_info=True)
raise DownloadError(f"下载失败: {e}") from e
finally:
if not download_success and temp_file and os.path.exists(temp_file):
try:
os.remove(temp_file)
logger.debug("清理临时文件")
except Exception:
pass

return None
with open(temp_file, "wb") as f:
f.write(content)
os.rename(temp_file, temp_file + ext)
return temp_file + ext
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The max_file_size configuration option is no longer enforced. The old code checked if total_size exceeded max_file_size during download and raised an error. The new code removes this check entirely, which means users could potentially download files much larger than the configured limit, leading to memory and storage issues.

Either remove the max_file_size configuration option if it's no longer needed, or implement size checking during the download process.

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines +377 to +379
yield event.chain_result([Record(path)])
if os.path.exists(path):
os.remove(path)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temporary segment files are deleted immediately after sending at line 378-379, but if the send operation fails or the Record component hasn't finished reading the file yet, this could cause errors. The old code was more careful about cleanup timing.

Consider ensuring the Record component has finished processing the file before deletion, or handle potential file-not-found errors gracefully.

Copilot uses AI. Check for mistakes.
Comment thread main.py
for i, start in enumerate(range(0, len(audio), seg_ms), 1):
path = f"{temp_file}_{i}.wav"
audio[start : start + seg_ms].export(path, format="wav")
yield event.chain_result([Record(path)])
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Record component is instantiated incorrectly. Based on the import statement at line 13, Record is imported from astrbot.api.message_components. However, at line 377, the code uses Record(path) which assumes the constructor takes a file path directly.

Looking at the old code that was removed, it used Record.fromFileSystem(segment_file). The new code should verify the correct way to instantiate Record with a file path.

Suggested change
yield event.chain_result([Record(path)])
yield event.chain_result([Record.fromFileSystem(path)])

Copilot uses AI. Check for mistakes.
Comment thread main.py
async with self._http_session.get(url, allow_redirects=True) as resp:
if resp.status != 200:
return None
content = await resp.read()
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak: Downloaded song files are loaded entirely into memory with await resp.read(). For large audio files (up to 50MB by default configuration), this can consume significant memory. Multiple concurrent downloads could exhaust available memory.

Consider using streaming download with resp.content.iter_chunked() to write chunks directly to disk instead of loading the entire file into memory first.

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines +352 to +355
with open(temp_file, "wb") as f:
f.write(content)
os.rename(temp_file, temp_file + ext)
return temp_file + ext
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary file not cleaned up on download error. If an exception occurs after the file is created at line 352-353 but before the successful return at line 355 (e.g., os.rename fails), the temp_file will be left on disk. Over time, this could accumulate significant disk space usage.

Wrap the file operations in a try-finally block to ensure cleanup, or use a context manager to handle the temporary file.

Suggested change
with open(temp_file, "wb") as f:
f.write(content)
os.rename(temp_file, temp_file + ext)
return temp_file + ext
final_path = None
try:
with open(temp_file, "wb") as f:
f.write(content)
final_path = temp_file + ext
os.rename(temp_file, final_path)
return final_path
finally:
if final_path is None and os.path.exists(temp_file):
os.remove(temp_file)

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines +94 to +102
self._http_session = aiohttp.ClientSession(
timeout=REQUEST_TIMEOUT,
headers={
"Referer": "https://astrbot.app/",
"User-Agent": f"AstrBot/{VERSION}",
"UAK": "AstrBot/plugin_meting",
},
)
self._initialized = True
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The terminate() method has been completely removed. This means the HTTP session will not be properly closed when the plugin is stopped, potentially leaving open connections and causing resource leaks. The old code had a terminate() method that closed the session and cleaned up resources.

Implement a terminate() or cleanup method to properly close the aiohttp session and release resources when the plugin is stopped.

Copilot uses AI. Check for mistakes.
Comment thread main.py
Comment on lines 91 to +102
async def _ensure_initialized(self):
"""确保插件已初始化(惰性初始化)"""
if self._initialized:
return
self._http_session = aiohttp.ClientSession(
timeout=REQUEST_TIMEOUT,
headers={
"Referer": "https://astrbot.app/",
"User-Agent": f"AstrBot/{VERSION}",
"UAK": "AstrBot/plugin_meting",
},
)
self._initialized = True
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition in session initialization. The _ensure_initialized method checks self._initialized without a lock at line 92, then only acquires the sessions_lock later. If two requests come in simultaneously, both could pass the first check and attempt initialization. While the second request won't re-initialize due to the second check, this pattern is fragile.

Use a dedicated initialization lock (as the old code did with _init_lock) to ensure thread-safe initialization, or use asyncio.Lock properly around the entire initialization check and process.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants