Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions providers/discord/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
:caption: Guides

Connection types <connections/discord-webhook>
Discord Notifications <notifications/index>

.. toctree::
:hidden:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.. Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

.. http://www.apache.org/licenses/LICENSE-2.0

.. Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.

How-to Guide for Discord notifications
========================================

Introduction
------------
Discord notifier (:class:`airflow.providers.discord.notifications.discord.DiscordNotifier`) allows users to send
messages to a discord channel using the various ``on_*_callbacks`` at both the Dag level and Task level


Example Code:
-------------

.. code-block:: python

from datetime import datetime
from airflow import DAG
from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.discord.notifications.embed import Embed, EmbedAuthor, EmbedFooter, EmbedField
from airflow.providers.discord.notifications.discord import DiscordNotifier

with DAG(
start_date=datetime(2025, 1, 1),
on_success_callback=[
DiscordNotifier(
text="The Dag {{ dag.dag_id }} succeeded",
)
],
):
BashOperator(
task_id="mytask",
on_failure_callback=[
DiscordNotifier(
text="The task {{ ti.task_id }} failed",
embed=Embed(
title="Hello ~~people~~ world :wave: The task {{ ti.task_id }} failed",
description="You can use [links](https://discord.com) or emojis :smile: 😎\n```\nAnd also code blocks\n```",
color=4321431,
timestamp="2025-11-23T19:05:15.292Z",
url="https://discord.com",
author=EmbedAuthor(
name="Author",
url="https://discord.com",
icon_url="https://cdn.discordapp.com/embed/avatars/0.png",
),
footer=EmbedFooter(
text="Footer text", icon_url="https://cdn.discordapp.com/embed/avatars/0.png"
),
fields=[EmbedField(name="Field 1, *lorem* **ipsum**, ~~dolor~~", value="Field value")],
),
)
],
bash_command="fail",
)
27 changes: 27 additions & 0 deletions providers/discord/docs/notifications/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.. Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

.. http://www.apache.org/licenses/LICENSE-2.0

.. Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.



Discord Notifications
=====================

.. toctree::
:maxdepth: 1
:glob:

*
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

if TYPE_CHECKING:
from airflow.providers.common.compat.sdk import Connection
from airflow.providers.discord.notifications.embed import Embed


class DiscordCommonHandler:
Expand Down Expand Up @@ -59,8 +60,53 @@ def get_webhook_endpoint(self, conn: Connection | None, webhook_endpoint: str |

return endpoint

def validate_embed(
self,
*,
embed: Embed,
) -> Embed:
"""
Validate Discord Embed JSON payload.

Validates the embed object against Discord limits. See:
https://discord.com/developers/docs/resources/message#embed-object-embed-limits

:param embed: Discord embed object.
:return: Discord embed object.
"""
# Validate title
if "title" in embed and len(embed["title"]) > 256:
raise ValueError("Discord embed title must be 256 or fewer characters")
# Validate description
if "description" in embed and len(embed["description"]) > 4096:
raise ValueError("Discord embed description must be 4096 or fewer characters")
# Validate footer
if "footer" in embed:
if len(embed["footer"]["text"]) > 2048:
raise ValueError("Discord embed footer text must be 2048 or fewer characters")
# Validate author
if "author" in embed:
if len(embed["author"]["name"]) > 2048:
raise ValueError("Discord embed author name must be 256 or fewer characters")
# Validate fields
if "fields" in embed:
if len(embed["fields"]) > 25:
raise ValueError("Discord embed fields must be 25 or fewer items")
for field in embed["fields"]:
if len(field["name"]) > 256:
raise ValueError("Discord embed field name must be 256 or fewer characters")
if len(field["value"]) > 1024:
raise ValueError("Discord embed field value must be 1024 or fewer characters")
return embed

def build_discord_payload(
self, *, tts: bool, message: str, username: str | None, avatar_url: str | None
self,
*,
tts: bool,
message: str,
username: str | None,
avatar_url: str | None,
embed: Embed | None = None,
) -> str:
"""
Build a valid Discord JSON payload.
Expand All @@ -70,6 +116,7 @@ def build_discord_payload(
(max 2000 characters)
:param username: Override the default username of the webhook
:param avatar_url: Override the default avatar of the webhook
:param embed: Discord embed object.
:return: Discord payload (str) to send
"""
if len(message) > 2000:
Expand All @@ -82,6 +129,8 @@ def build_discord_payload(
payload["username"] = username
if avatar_url:
payload["avatar_url"] = avatar_url
if embed:
payload["embeds"] = [self.validate_embed(embed=embed)]
return json.dumps(payload)


Expand All @@ -107,6 +156,7 @@ class DiscordWebhookHook(HttpHook):
:param avatar_url: Override the default avatar of the webhook
:param tts: Is a text-to-speech message
:param proxy: Proxy to use to make the Discord webhook call
:param embed: Discord embed object.
"""

conn_name_attr = "http_conn_id"
Expand Down Expand Up @@ -140,6 +190,7 @@ def __init__(
avatar_url: str | None = None,
tts: bool = False,
proxy: str | None = None,
embed: Embed | None = None,
*args: Any,
**kwargs: Any,
) -> None:
Expand All @@ -152,6 +203,7 @@ def __init__(
self.avatar_url = avatar_url
self.tts = tts
self.proxy = proxy
self.embed = embed

def _get_webhook_endpoint(self, http_conn_id: str | None, webhook_endpoint: str | None) -> str:
"""
Expand All @@ -174,7 +226,11 @@ def execute(self) -> None:
proxies = {"https": self.proxy}

discord_payload = self.handler.build_discord_payload(
tts=self.tts, message=self.message, username=self.username, avatar_url=self.avatar_url
tts=self.tts,
message=self.message,
username=self.username,
avatar_url=self.avatar_url,
embed=self.embed,
)

self.run(
Expand Down Expand Up @@ -207,6 +263,7 @@ class DiscordWebhookAsyncHook(HttpAsyncHook):
:param avatar_url: Override the default avatar of the webhook
:param tts: Is a text-to-speech message
:param proxy: Proxy to use to make the Discord webhook call
:param embed: Discord embed object.
"""

default_headers = {
Expand All @@ -227,6 +284,7 @@ def __init__(
avatar_url: str | None = None,
tts: bool = False,
proxy: str | None = None,
embed: Embed | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
Expand All @@ -237,6 +295,7 @@ def __init__(
self.avatar_url = avatar_url
self.tts = tts
self.proxy = proxy
self.embed = embed
self.handler = DiscordCommonHandler()

async def _get_webhook_endpoint(self) -> str:
Expand All @@ -256,9 +315,12 @@ async def execute(self) -> None:
"""Execute the Discord webhook call."""
webhook_endpoint = await self._get_webhook_endpoint()
discord_payload = self.handler.build_discord_payload(
tts=self.tts, message=self.message, username=self.username, avatar_url=self.avatar_url
tts=self.tts,
message=self.message,
username=self.username,
avatar_url=self.avatar_url,
embed=self.embed,
)

async with aiohttp.ClientSession(proxy=self.proxy) as session:
await super().run(
session=session,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
from __future__ import annotations

from functools import cached_property
from typing import TYPE_CHECKING

from airflow.providers.common.compat.notifier import BaseNotifier
from airflow.providers.discord.hooks.discord_webhook import DiscordWebhookAsyncHook, DiscordWebhookHook
from airflow.providers.discord.version_compat import AIRFLOW_V_3_1_PLUS

if TYPE_CHECKING:
from airflow.providers.discord.notifications.embed import Embed

ICON_URL: str = (
"https://raw.githubusercontent.com/apache/airflow/main/airflow-core/src/airflow/ui/public/pin_100.png"
)
Expand All @@ -39,18 +43,21 @@ class DiscordNotifier(BaseNotifier):
:param username: The username to send the message as. Optional
:param avatar_url: The URL of the avatar to use for the message. Optional
:param tts: Text to speech.
:param embed: Discord embed object. See:
https://discord.com/developers/docs/resources/message#embed-object-embed-author-structure
"""

# A property that specifies the attributes that can be templated.
template_fields = ("discord_conn_id", "text", "username", "avatar_url", "tts")
template_fields = ("discord_conn_id", "text", "username", "avatar_url", "tts", "embed")

def __init__(
self,
discord_conn_id: str = "discord_webhook_default",
text: str = "This is a default message",
text: str = "",
username: str = "Airflow",
avatar_url: str = ICON_URL,
tts: bool = False,
embed: Embed | None = None,
**kwargs,
):
if AIRFLOW_V_3_1_PLUS:
Expand All @@ -66,6 +73,7 @@ def __init__(
# If you're having problems with tts not being recognized in __init__(),
# you can define that after instantiating the class
self.tts = tts
self.embed = embed

@cached_property
def hook(self) -> DiscordWebhookHook:
Expand All @@ -81,6 +89,7 @@ def hook_async(self) -> DiscordWebhookAsyncHook:
username=self.username,
avatar_url=self.avatar_url,
tts=self.tts,
embed=self.embed,
)

def notify(self, context):
Expand All @@ -94,6 +103,7 @@ def notify(self, context):
self.hook.message = self.text
self.hook.avatar_url = self.avatar_url
self.hook.tts = self.tts
self.hook.embed = self.embed

self.hook.execute()

Expand Down
Loading
Loading