# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2025, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta, timezone from json import loads # Disable logging for a cleaner testing output import logging import os from random import choice from string import ascii_uppercase as str_alpha, digits as str_num from unittest import mock from helpers import AppriseURLTester import pytest import requests from apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType from apprise.common import OverflowMode from apprise.plugins.discord import NotifyDiscord logging.disable(logging.CRITICAL) # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), "var") # Our Testing URLs apprise_url_tests = ( ( "discord://", { "instance": TypeError, }, ), # An invalid url ( "discord://:@/", { "instance": TypeError, }, ), # No webhook_token specified ( "discord://%s" % ("i" * 24), { "instance": TypeError, }, ), # Provide both an webhook id and a webhook token ( "discord://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Provide a temporary username ( "discord://l2g@{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # test image= field ( "discord://{}/{}?format=markdown&footer=Yes&image=Yes".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "discord://{}/{}?format=markdown&footer=Yes&image=No&fields=no".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://jack@{}/{}?format=markdown&footer=Yes&image=Yes".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, "privacy_url": "discord://jack@i...i/t...t/", }, ), ( "https://discord.com/api/webhooks/{}/{}".format("0" * 10, "B" * 40), { # Native URL Support, support the provided discord URL from their # webpage. "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "https://discordapp.com/api/webhooks/{}/{}".format("0" * 10, "B" * 40), { # Legacy Native URL Support, support the older URL (to be # decomissioned on Nov 7th 2020) "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "https://discordapp.com/api/webhooks/{}/{}?footer=yes".format( "0" * 10, "B" * 40 ), { # Native URL Support with arguments "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, "privacy_url": "discord://0...0/B...B/", }, ), ( "https://discordapp.com/api/webhooks/{}/{}?footer=yes&botname=joe" .format("0" * 10, "B" * 40), { # Native URL Support with arguments "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, "privacy_url": "discord://joe@0...0/B...B/", }, ), ( "discord://{}/{}?format=markdown&avatar=No&footer=No".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # different format support ( "discord://{}/{}?format=markdown".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Thread ID ( "discord://{}/{}?format=markdown&thread=abc123".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), ( "discord://{}/{}?format=text".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test with href (title link) ( "discord://{}/{}?hmarkdown=true&ref=http://localhost".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test with url (title link) - Alias of href ( "discord://{}/{}?markdown=true&url=http://localhost".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test with avatar URL ( "discord://{}/{}?avatar_url=http://localhost/test.jpg".format( "i" * 24, "t" * 64 ), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, }, ), # Test without image set ( "discord://{}/{}".format("i" * 24, "t" * 64), { "instance": NotifyDiscord, "requests_response_code": requests.codes.no_content, # don't include an image by default "include_image": False, }, ), ( "discord://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyDiscord, # force a failure "response": False, "requests_response_code": requests.codes.internal_server_error, }, ), ( "discord://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyDiscord, # throw a bizzare code forcing us to fail to look it up "response": False, "requests_response_code": 999, }, ), ( "discord://{}/{}/".format("a" * 24, "b" * 64), { "instance": NotifyDiscord, # Throws a series of i/o exceptions with this flag # is set and tests that we gracfully handle them "test_requests_exceptions": True, }, ), ) def test_plugin_discord_urls(): """NotifyDiscord() Apprise URLs.""" # Run our general tests AppriseURLTester(tests=apprise_url_tests).run_all() @mock.patch("requests.post") def test_plugin_discord_notifications(mock_post): """NotifyDiscord() Notifications/Ping Support.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Test our header parsing when not lead with a header body = """ # Heading @everyone and @admin, wake and meet our new user <@123>; <@&456>" """ results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) 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 "users" in payload["allow_mentions"] assert len(payload["allow_mentions"]["users"]) == 1 assert "123" in payload["allow_mentions"]["users"] assert "roles" in payload["allow_mentions"] assert len(payload["allow_mentions"]["roles"]) == 1 assert "456" in payload["allow_mentions"]["roles"] assert "parse" in payload["allow_mentions"] assert len(payload["allow_mentions"]["parse"]) == 2 assert "everyone" in payload["allow_mentions"]["parse"] assert "admin" in payload["allow_mentions"]["parse"] # Reset our object mock_post.reset_mock() results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?format=text" ) 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 "users" in payload["allow_mentions"] assert len(payload["allow_mentions"]["users"]) == 1 assert "123" in payload["allow_mentions"]["users"] assert "roles" in payload["allow_mentions"] assert len(payload["allow_mentions"]["roles"]) == 1 assert "456" in payload["allow_mentions"]["roles"] assert "parse" in payload["allow_mentions"] assert len(payload["allow_mentions"]["parse"]) == 2 assert "everyone" in payload["allow_mentions"]["parse"] assert "admin" in payload["allow_mentions"]["parse"] @mock.patch("requests.post") @mock.patch("time.sleep") def test_plugin_discord_general(mock_sleep, mock_post): """NotifyDiscord() General Checks.""" # Prevent throttling mock_sleep.return_value = True # Turn off clock skew for local testing NotifyDiscord.clock_skew = timedelta(seconds=0) # Epoch time: epoch = datetime.fromtimestamp(0, timezone.utc) # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.content = "" mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Invalid webhook id with pytest.raises(TypeError): NotifyDiscord(webhook_id=None, webhook_token=webhook_token) # Invalid webhook id (whitespace) with pytest.raises(TypeError): NotifyDiscord(webhook_id=" ", webhook_token=webhook_token) # Invalid webhook token with pytest.raises(TypeError): NotifyDiscord(webhook_id=webhook_id, webhook_token=None) # Invalid webhook token (whitespace) with pytest.raises(TypeError): NotifyDiscord(webhook_id=webhook_id, webhook_token=" ") obj = NotifyDiscord( webhook_id=webhook_id, webhook_token=webhook_token, footer=True, thumbnail=False, ) assert obj.ratelimit_remaining == 1 # Test that we get a string response assert isinstance(obj.url(), str) is True # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Force a case where there are no more remaining posts allowed mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 0, } # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # behind the scenes, it should cause us to update our rate limit assert obj.send(body="test") is True assert obj.ratelimit_remaining == 0 # This should cause us to block mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 10, } assert obj.send(body="test") is True assert obj.ratelimit_remaining == 10 # Reset our variable back to 1 mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # Handle cases where our epoch time is wrong del mock_post.return_value.headers["X-RateLimit-Reset"] assert obj.send(body="test") is True # Return our object, but place it in the future forcing us to block mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() + 1 ), "X-RateLimit-Remaining": 0, } obj.ratelimit_remaining = 0 assert obj.send(body="test") is True # Test 429 error response mock_post.return_value.status_code = requests.codes.too_many_requests # The below will attempt a second transmission and fail (because we didn't # set up a second post request to pass) :) assert obj.send(body="test") is False # Return our object, but place it in the future forcing us to block mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() - 1 ), "X-RateLimit-Remaining": 0, } assert obj.send(body="test") is True # Return our limits to always work obj.ratelimit_remaining = 1 # Return our headers to normal mock_post.return_value.headers = { "X-RateLimit-Reset": ( (datetime.now(timezone.utc) - epoch).total_seconds() ), "X-RateLimit-Remaining": 1, } # This call includes an image with it's payload: assert ( obj.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Simple Markdown Single line of text test_markdown = "body" desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 # Test our header parsing when not lead with a header test_markdown = """ A section of text that has no header at the top. It also has a hash tag # <- in the middle of a string. ## Heading 1 body # Heading 2 more content on multi-lines """ desc, results = obj.extract_markdown_sections(test_markdown) # we have a description assert isinstance(desc, str) is True assert desc.startswith("A section of text that has no header at the top.") assert desc.endswith("string.") assert isinstance(results, list) is True assert len(results) == 2 assert results[0]["name"] == "Heading 1" assert results[0]["value"] == "```md\nbody\n```" assert results[1]["name"] == "Heading 2" assert ( results[1]["value"] == "```md\nmore content\n on multi-lines\n```" ) # Test our header parsing test_markdown = ( "## Heading one\nbody body\n\n" + "# Heading 2 ##\n\nTest\n\n" + "more content\n" + "even more content \t\r\n\n\n" + "# Heading 3 ##\n\n\n" + "normal content\n" + "# heading 4\n" + "#### Heading 5" ) desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True # No desc details filled out assert isinstance(desc, str) is True assert not desc # We should have 5 sections (since there are 5 headers identified above) assert len(results) == 5 assert results[0]["name"] == "Heading one" assert results[0]["value"] == "```md\nbody body\n```" assert results[1]["name"] == "Heading 2" assert ( results[1]["value"] == "```md\nTest\n\nmore content\neven more content\n```" ) assert results[2]["name"] == "Heading 3" assert results[2]["value"] == "```md\nnormal content\n```" assert results[3]["name"] == "heading 4" assert results[3]["value"] == "```\n```" assert results[4]["name"] == "Heading 5" assert results[4]["value"] == "```\n```" # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert ( a.add( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) # This call includes an image with it's payload: NotifyDiscord.discord_max_fields = 1 assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) # Throw an exception on the forth call to requests.post() # This allows to test our batch field processing response = mock.Mock() response.content = "" response.status_code = requests.codes.ok mock_post.return_value = response mock_post.side_effect = [ response, response, response, requests.RequestException(), ] # Test our markdown obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) assert isinstance(obj, NotifyDiscord) assert ( obj.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO ) is False ) mock_post.side_effect = None # Empty String desc, results = obj.extract_markdown_sections("") assert isinstance(results, list) is True assert len(results) == 0 # No desc details filled out assert isinstance(desc, str) is True assert not desc # String without Heading test_markdown = ( "Just a string without any header entries.\n" + "A second line" ) desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 # No desc details filled out assert isinstance(desc, str) is True assert ( desc == "Just a string without any header entries.\n" + "A second line" ) # Use our test markdown string during a notification assert ( obj.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO ) is True ) # Create an apprise instance a = Apprise() # Our processing is slightly different when we aren't using markdown # as we do not pre-parse content during our notifications assert ( a.add( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.MARKDOWN, ) is True ) # Toggle our logo availability a.asset.image_url_logo = None assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) # Create an apprise instance a = Apprise() # Reset our object mock_post.reset_mock() # Test our threading assert ( a.add(f"discord://{webhook_id}/{webhook_token}/?thread=12345") is True ) # This call includes an image with it's payload: assert a.notify(body="test", title="title") is True assert mock_post.call_count == 1 response = mock_post.call_args_list[0][1] assert "params" in response assert response["params"].get("thread_id") == "12345" @mock.patch("requests.post") def test_plugin_discord_overflow(mock_post): """NotifyDiscord() Overflow Checks.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Some variables we use to control the data we work with body_len = 8000 title_len = 1024 # Number of characters per line row = 24 # Create a large body and title with random data body = "".join(choice(str_alpha + str_num + " ") for _ in range(body_len)) body = "\r\n".join([body[i : i + row] for i in range(0, len(body), row)]) # Create our title using random data title = "".join(choice(str_alpha + str_num) for _ in range(title_len)) results = NotifyDiscord.parse_url( f"discord://{webhook_id}/{webhook_token}/?overflow=split" ) 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) results = instance._apply_overflow( body, title=title, overflow=OverflowMode.SPLIT ) # Ensure we never exceed 2000 characters for entry in results: assert len(entry["title"]) <= instance.title_maxlen assert len(entry["title"]) + len(entry["body"]) <= instance.body_maxlen @mock.patch("requests.post") def test_plugin_discord_markdown_extra(mock_post): """NotifyDiscord() Markdown Extra Checks.""" # Initialize some generic (but valid) tokens webhook_id = "A" * 24 webhook_token = "B" * 64 # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok # Reset our apprise object a = Apprise() # We want to further test our markdown support to accomodate bug rased on # 2022.10.25; see https://github.com/caronc/apprise/issues/717 assert ( a.add( f"discord://{webhook_id}/{webhook_token}/" "?format=markdown&footer=Yes" ) is True ) test_markdown = "[green-blue](https://google.com)" # This call includes an image with it's payload: assert ( a.notify( body=test_markdown, title="title", notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT, ) is True ) assert ( a.notify(body="body", title="title", notify_type=NotifyType.INFO) is True ) @mock.patch("requests.post") def test_plugin_discord_attachments(mock_post): """NotifyDiscord() Attachment Checks.""" # Initialize some generic (but valid) tokens webhook_id = "C" * 24 webhook_token = "D" * 64 # Prepare a good response response = mock.Mock() response.status_code = requests.codes.ok # Prepare a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error # Prepare Mock return object mock_post.return_value = response # Test our markdown obj = Apprise.instantiate( f"discord://{webhook_id}/{webhook_token}/?format=markdown" ) # attach our content attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) assert ( mock_post.call_args_list[1][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) # Reset our object mock_post.reset_mock() # Test notifications with mentions and attachments in it assert ( obj.notify( body="Say hello to <@1234>!", notify_type=NotifyType.INFO, attach=attach, ) is True ) # Test our call count assert mock_post.call_count == 2 assert ( mock_post.call_args_list[0][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) assert ( mock_post.call_args_list[1][0][0] == f"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" ) # Reset our object mock_post.reset_mock() # An invalid attachment will cause a failure path = os.path.join(TEST_VAR_DIR, "/invalid/path/to/an/invalid/file.jpg") attach = AppriseAttachment(path) assert ( obj.notify( body="body", title="title", notify_type=NotifyType.INFO, attach=path, ) is False ) # update our attachment to be valid attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, "apprise-test.gif")) mock_post.return_value = None # Throw an exception on the first call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # Throw an exception on the second call to requests.post() for side_effect in (requests.RequestException(), OSError(), bad_response): mock_post.side_effect = [response, side_effect] # We'll fail now because of our error handling assert obj.send(body="test", attach=attach) is False # handle a bad response bad_response = mock.Mock() bad_response.status_code = requests.codes.internal_server_error mock_post.side_effect = [response, bad_response] # We'll fail now because of an internal exception assert obj.send(body="test", attach=attach) is False