discord:// supports ping= feature (#1466)

This commit is contained in:
Chris Caron
2025-12-06 16:01:06 -05:00
committed by GitHub
parent c0095cd110
commit 2958a900e9
2 changed files with 125 additions and 31 deletions

View File

@@ -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<role>&?)(?P<id>[0-9]+)>|@(?P<value>[a-z0-9]+))", re.I
r"\s*(?:<?@(?P<role>&?)(?P<id>[0-9]+)>?|@(?P<value>[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

View File

@@ -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")