From 2958a900e926d9b76e6622edac80b3319f6e2d5e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 6 Dec 2025 16:01:06 -0500 Subject: [PATCH] discord:// supports ping= feature (#1466) --- apprise/plugins/discord.py | 105 +++++++++++++++++++++++++---------- tests/test_plugin_discord.py | 51 ++++++++++++++++- 2 files changed, 125 insertions(+), 31 deletions(-) diff --git a/apprise/plugins/discord.py b/apprise/plugins/discord.py index 653fb67f..48838112 100644 --- a/apprise/plugins/discord.py +++ b/apprise/plugins/discord.py @@ -43,6 +43,7 @@ # - https://discord.com/developers/docs/resources/webhook # from datetime import datetime, timedelta, timezone +from itertools import chain from json import dumps import re @@ -51,12 +52,12 @@ import requests from ..attachment.base import AttachBase from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ -from ..utils.parse import parse_bool, validate_regex +from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase # Used to detect user/role IDs USER_ROLE_DETECTION_RE = re.compile( - r"\s*(?:<@(?P&?)(?P[0-9]+)>|@(?P[a-z0-9]+))", re.I + r"\s*(?:&?)(?P[0-9]+)>?|@(?P[a-z0-9]+))", re.I ) @@ -194,6 +195,10 @@ class NotifyDiscord(NotifyBase): "default": False, "map_to": "include_image", }, + "ping": { + "name": _("Ping Users/Roles"), + "type": "list:string", + }, }, ) @@ -211,6 +216,7 @@ class NotifyDiscord(NotifyBase): href=None, thread=None, flags=None, + ping=None, **kwargs, ): """Initialize Discord Object.""" @@ -285,6 +291,9 @@ class NotifyDiscord(NotifyBase): # Default to 1.0 self.ratelimit_remaining = 1.0 + # Ping targets (raw tokens from URL, already split by parse_list) + self.ping = parse_list(ping) + return def send( @@ -324,6 +333,9 @@ class NotifyDiscord(NotifyBase): # Associate our thread_id with our message params = {"thread_id": self.thread_id} if self.thread_id else None + # Apply any pingable content to the payload + payload.update(self.ping_payload(body, " ".join(self.ping))) + if body: # our fields variable fields = [] @@ -383,34 +395,7 @@ class NotifyDiscord(NotifyBase): # not markdown payload["content"] = ( body if not title else f"{title}\r\n{body}" - ) - - # parse for user id's <@123> and role IDs <@&456> - results = USER_ROLE_DETECTION_RE.findall(body) - if results: - payload["allow_mentions"] = { - "parse": [], - "users": [], - "roles": [], - } - - _content = [] - for is_role, no, value in results: - if value: - payload["allow_mentions"]["parse"].append(value) - _content.append(f"@{value}") - - elif is_role: - payload["allow_mentions"]["roles"].append(no) - _content.append(f"<@&{no}>") - - else: # is_user - payload["allow_mentions"]["users"].append(no) - _content.append(f"<@{no}>") - - if self.notify_format == NotifyFormat.MARKDOWN: - # Add pingable elements to content field - payload["content"] = "👉 " + " ".join(_content) + ) + payload.get("content", "") if not self._send(payload, params=params): # We failed to post our message @@ -660,6 +645,9 @@ class NotifyDiscord(NotifyBase): if self.thread_id: params["thread"] = self.thread_id + if self.ping: + params["ping"] = ",".join(self.ping) + # Ensure our botname is set botname = f"{self.user}@" if self.user else "" @@ -771,6 +759,10 @@ class NotifyDiscord(NotifyBase): # Markdown is implied results["format"] = NotifyFormat.MARKDOWN + # Extract ping targets, comma/space separated + if "ping" in results["qsd"]: + results["ping"] = NotifyDiscord.unquote(results["qsd"]["ping"]) + return results @staticmethod @@ -806,6 +798,59 @@ class NotifyDiscord(NotifyBase): return None + def ping_payload(self, *args): + """ + Takes a body and applies the payload associated with pinging + the users detected within + """ + + # initialize a payload object we can prepare + payload = {} + + roles = set() + users = set() + parse = set() + + for arg in args: + # parse for user id's <@123> and role IDs <@&456> + results = USER_ROLE_DETECTION_RE.findall(arg) + if not results: + continue + + _content = [] + for is_role, no, value in results: + if value: + parse.add(value) + _content.append(f"@{value}") + + elif is_role: + roles.add(no) + _content.append(f"<@&{no}>") + + else: # is_user + users.add(no) + _content.append(f"<@{no}>") + + if not (roles or users or parse): + # Nothing to add + return payload + + # First time through... + payload["allow_mentions"] = { + "parse": list(parse), + "users": list(users), + "roles": list(roles), + } + + # Add pingable elements to content field + payload["content"] = "👉 " + " ".join(chain( + [f"@{value}" for value in parse], + [f"<@&{value}>" for value in roles], + [f"<@{value}>" for value in users], + )) + + return payload + @staticmethod def extract_markdown_sections(markdown): """Takes a string in a markdown type format and extracts the headers diff --git a/tests/test_plugin_discord.py b/tests/test_plugin_discord.py index 73dd2fe7..266f7c27 100644 --- a/tests/test_plugin_discord.py +++ b/tests/test_plugin_discord.py @@ -88,7 +88,7 @@ apprise_url_tests = ( ), # test image= field ( - "discord://{}/{}?format=markdown&footer=Yes&image=Yes".format( + "discord://{}/{}?format=markdown&footer=Yes&image=Yes&ping=Joe".format( "i" * 24, "t" * 64 ), { @@ -404,6 +404,55 @@ def test_plugin_discord_notifications(mock_post): assert "everyone" in payload["allow_mentions"]["parse"] assert "admin" in payload["allow_mentions"]["parse"] + # Reset our object + mock_post.reset_mock() + + # Test our header parsing when not lead with a header + body = """ """ + + results = NotifyDiscord.parse_url( + # & -> %26 for role otherwise & separates our URL from further parsing + f"discord://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>" + ) + + assert isinstance(results, dict) + assert results["user"] is None + assert results["webhook_id"] == webhook_id + assert results["webhook_token"] == webhook_token + assert results["password"] is None + assert results["port"] is None + assert results["host"] == webhook_id + assert results["fullpath"] == f"/{webhook_token}/" + assert results["path"] == f"/{webhook_token}/" + assert results["query"] is None + assert results["schema"] == "discord" + assert results["url"] == f"discord://{webhook_id}/{webhook_token}/" + instance = NotifyDiscord(**results) + assert isinstance(instance, NotifyDiscord) + + response = instance.send(body=body) + assert response is True + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + assert ( + details[0][0] + == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" + ) + + payload = loads(details[1]["data"]) + + assert "allow_mentions" in payload + assert len(payload["allow_mentions"]["users"]) == 1 + assert "321" in payload["allow_mentions"]["users"] + assert "<@321>" in payload["content"] + assert len(payload["allow_mentions"]["roles"]) == 1 + assert "654" in payload["allow_mentions"]["roles"] + assert "<@&654>" in payload["content"] + assert len(payload["allow_mentions"]["parse"]) == 1 + assert "joe" in payload["allow_mentions"]["parse"] + assert "@joe" in payload["content"] + @mock.patch("requests.post") @mock.patch("time.sleep")