Commit 64919df

benny-dou <60535774+benny-dou@users.noreply.github.com>
2025-08-07 04:19:02
feat(netease): add support for Netease Music links
1 parent 7fabb90
src/bridge/social.py
@@ -20,9 +20,11 @@ SOCIAL_BOTS = {
     "KyDownloaderBot": ["bilibili", "douyin", "tiktok", "instagram", "instagram", "weibo", "x", "xiaohongshu", "youtube"],
     "DouYintg_bot": ["bilibili", "douyin", "tiktok", "instagram", "instagram", "weibo", "x", "xiaohongshu", "youtube"],
     "MultiSaverXbot": ["bilibili", "douyin", "tiktok", "instagram", "instagram", "weibo", "x", "xiaohongshu", "youtube"],
+    "Music163bot": ["music163"],
 }
 
 CAPTIONS = {"MultiSaverXbot": ""}
+MEDIA_TYPES = {"Music163bot": ["audio"]}  # default: ['video', 'photo']
 
 
 async def send_to_social_media_bridge(client: Client, message: Message, url: str, platform: str, **kwargs):
@@ -32,7 +34,7 @@ async def send_to_social_media_bridge(client: Client, message: Message, url: str
 
     params = {
         "target_cid": kwargs["target_chat"] if kwargs.get("target_chat") else message.chat.id,  # MSG-A's cid
-        "target_mid": None,  # disable reply, because the reply message may be sent as a series of messages.
+        "target_mid": kwargs.get("target_mid"),  # disable reply, because the reply message may be sent as a series of messages.
         "url": url,
     }
 
@@ -60,11 +62,24 @@ async def forward_social_media_results(client: Client, message: Message):
     First, we need to check the global flag in the cache. Format: social-global-{url}
     Then, we need to check the cache for each bot. Format: social-individual-{bot}-{url}
     """
-    if message.from_user.username not in SOCIAL_BOTS or not (message.photo or message.video):
+    if message.from_user.username not in SOCIAL_BOTS:
         return
+    # check media type
+    if MEDIA_TYPES.get(message.from_user.username):
+        info = parse_msg(message, silent=True)
+        if info["mtype"] not in MEDIA_TYPES[message.from_user.username]:
+            return
+    elif not (message.photo or message.video):  # By default, only forward video and photo messages
+        return
+
+    # ! Fix wired bug. When @Music163bot send us an audio file, the message was recieved 2 times.
+    # ! We only want to process it once
+    if cache.get(f"social-processed-{message.id}"):
+        logger.warning("Already processed, skipping")
+        return
+    cache.set(f"social-processed-{message.id}", "Done", ttl=120)  # cache id for 2 minutes
     #  got a media message
     info = parse_msg(message)
-
     bot_name = cache.get("social-global", default="")
     if bot_name == "waiting-for-bots":  # this bot is the first one to get the results
         logger.success(f"Bridge @{info['handle']} is the first bot to get a {info['mtype']}")
@@ -73,9 +88,10 @@ async def forward_social_media_results(client: Client, message: Message):
         return
     params = cache.get(f"social-{info['handle']}")
     if not params:
+        logger.warning("Already processed, skipping")
         return
 
-    logger.info(f"Forwarding {info['mtype']} from @{bot_name} -> chat={params['target_cid']}, id={params['target_mid']}")
+    logger.info(f"Forwarding {info['mtype']} from @{info['handle']} -> chat={params['target_cid']}, id={params['target_mid']}")
     await client.copy_message(
         chat_id=params["target_cid"],
         from_chat_id=info["cid"],
@@ -83,7 +99,6 @@ async def forward_social_media_results(client: Client, message: Message):
         caption=CAPTIONS.get(info["handle"]),  # type: ignore
         reply_parameters=ReplyParameters(message_id=params["target_mid"]),
     )
-
     # ! Because `cache` is not shared between different processes, we can't safely use it to store media_group_id
     # if not message.media_group_id:  # single media, send diectly
     #     logger.info(f"Forwarding {info['mtype']} from @{bot_name} -> chat={params['target_cid']}, id={params['target_mid']}")
@@ -99,3 +114,7 @@ async def forward_social_media_results(client: Client, message: Message):
     with contextlib.suppress(Exception):
         if params.get("prog_cid") and params.get("prog_mid"):
             await client.delete_messages(chat_id=params["prog_cid"], message_ids=params["prog_mid"])
+        # only handle once
+        if info["handle"] == "Music163bot":
+            cache.delete("social-global")
+            cache.delete("social-Music163bot")
src/preview/netease.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from pyrogram.client import Client
+from pyrogram.types import Message
+
+from bridge.social import send_to_social_media_bridge
+from messages.sender import send2tg
+
+
+async def preview_music163(client: Client, message: Message, url: str = "", **kwargs):
+    """Preview music163 link in the message.
+
+    Args:
+        client (Client): The Pyrogram client.
+        message (Message): The trigger message object.
+        url (str, optional): Netease Music link.
+    """
+    status_msg = f"🔗正在解析Netease Music链接:\n{url}\n\n"
+    status_msg += "🤖调用第三方服务: @Music163bot\n"
+    status_msg += "您还可以发送歌曲名给 @VmomoVBot"
+    if kwargs.get("show_progress") and "progress" not in kwargs:
+        res = await send2tg(client, message, texts=status_msg, **kwargs)
+        kwargs["progress"] = res[0]
+    kwargs |= {"target_mid": message.id}  # record the trigger msg_id as target_mid in kwargs
+    await send_to_social_media_bridge(client, message, url, **kwargs)
src/config.py
@@ -55,6 +55,7 @@ class ENABLE:  # see fine-grained permission in `src/permission.py`
     REDDIT = os.getenv("ENABLE_REDDIT", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     WGET = os.getenv("ENABLE_WGET", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     GITHUB = os.getenv("ENABLE_GITHUB", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
+    MUSIC163 = os.getenv("ENABLE_MUSIC163", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     XHS = os.getenv("ENABLE_XHS", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     YTDLP = os.getenv("ENABLE_YTDLP", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
     RAW_IMG_CONVERT = os.getenv("ENABLE_RAW_IMG_CONVERT", "1").lower() in ["1", "y", "yes", "t", "true", "on"]
src/handler.py
@@ -30,6 +30,7 @@ from preview.bilibili import preview_bilibili
 from preview.douyin import preview_douyin
 from preview.github import preview_github
 from preview.instagram import preview_instagram
+from preview.netease import preview_music163
 from preview.reddit import preview_reddit
 from preview.twitter import preview_twitter
 from preview.wechat import preview_wechat
@@ -156,6 +157,7 @@ async def handle_social_media(
     reddit: bool = True,
     github: bool = True,
     xhs: bool = True,
+    music163: bool = True,
     ytdlp: bool = True,
     show_progress: bool = True,
     detail_progress: bool = False,
@@ -261,6 +263,8 @@ async def handle_social_media(
             return await preview_github(client, message, **kwargs)
         if reddit and matched["platform"] == "reddit":
             return await preview_reddit(client, message, **kwargs)
+        if music163 and matched["platform"] == "music163":
+            return await preview_music163(client, message, **kwargs)
         if matched["platform"].startswith("bilibili-"):  # this is not bilibili video, for videos, use yt-dlp
             return await preview_bilibili(client, message, **kwargs)
         sent_messages = []
@@ -338,6 +342,8 @@ def get_social_media_help(chat_id: int | str, ctype: str, prefix: str):
         msg += "\n🎶TikTok"
     if permission["instagram"]:
         msg += "\n🏞Instagram"
+    if permission["music163"]:
+        msg += "\n🎧网易云音乐"
     if permission["reddit"]:
         msg += "\n🎈Reddit"
     if permission["wechat"]:
src/networking.py
@@ -361,6 +361,13 @@ async def match_social_media_link(text: str, *, flatten_first: bool = True) -> d
         url = f"https://github.com/{gh_user}/{gh_repo}"
         return {"url": url, "db_key": bare_url(url), "gh_user": gh_user, "gh_repo": gh_repo, "platform": "github"}
 
+    # https://music.163.com/song?id=2021343740
+    # https://163cn.tv/HYHqZ6R
+    # https://163cn.link/HYHqZ6R
+    if matched := re.search(r"(https?://)?(:?music\.163\.com|163cn\.tv|163cn\.link)/([0-9a-zA-Z#./?=_\-%&]+)", text):
+        url = matched.group(0)
+        return {"url": url, "db_key": bare_url(url), "platform": "music163"}
+
     # https://www.youtube.com/watch?v=D6aE2E0RHTc
     if matched := re.search(r"(https?://)?(:?m\.|www\.)?youtube\.com/watch([^,,.。\s]+)", text):
         queries = parse_qs(urlparse(matched.group(0)).query)
src/permission.py
@@ -116,6 +116,7 @@ def check_service(cid: int | str, ctype: str) -> dict:
         "twitter": True,
         "weibo": True,
         "xhs": True,
+        "music163": True,
         "github": True,
         "wechat": True,
         "reddit": True,
@@ -139,6 +140,8 @@ def check_service(cid: int | str, ctype: str) -> dict:
         permission["xhs"] = False
     if not ENABLE.GITHUB:
         permission["github"] = False
+    if not ENABLE.MUSIC163:
+        permission["music163"] = False
     if not ENABLE.DOUYIN:
         permission["douyin"] = False
     if not ENABLE.TIKTOK: