Source code for operators.communications.notify_slack
"""
NotifySlackOperator — post a notification message to a Slack channel.
Drop-in companion to EmailOperator for DAG failure/success callbacks.
Call .execute(context) inside a callback, or use as a standalone task.
Implementation notes
--------------------
- Uses SlackHook with Config.SLACK_DEFAULT_API ("slack_default_api" connection).
Same hook pattern used across the codebase (dbt notifications, ingest_slack, etc.).
- mrkdwn=True enables Slack markdown (*bold*, `code`, :emoji:) in the message body.
- @mentions: pass slack_user_ids directly via mention_user_ids (static, no API call), or
pass emails via mention_slack_emails. Email → Slack ID resolution order:
1. Local lookup via _get_slack_id (.agents/airflow-sso-users.json) — no API call
2. Slack API users.lookupByEmail (requires users:read.email scope — best-effort fallback)
nhat@ready.net | 2026-02-23
"""
import json
import os
from airflow.models import BaseOperator
from airflow.providers.slack.hooks.slack import SlackHook
from config import Config
# Inline SSO lookup — avoids importing from util (which would create a circular import
# via util/dag.py → NotifySlackOperator → util). Intentional small duplication.
_SSO_USERS: dict = {}
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
".agents", "airflow-sso-users.json")) as _f:
_SSO_USERS = json.load(_f)
except (FileNotFoundError, json.JSONDecodeError):
pass
def _get_slack_id(email: str | None) -> str | None:
if not email:
return None
entry = _SSO_USERS.get(email)
return entry.get("slack_user_id") if entry else None
[docs]
class NotifySlackOperator(BaseOperator):
"""
Post a notification message to a Slack channel.
Args:
channel: Slack channel name or ID, e.g. "#airflow"
text: Message body — supports Slack mrkdwn (*bold*, `code`, :emoji:)
mention_slack_emails: Emails to @mention at the top of the message. Resolved to
Slack user IDs via local SSO lookup first, then Slack API
users.lookupByEmail as fallback. Best-effort: unresolved
emails are silently skipped.
mention_user_ids: Slack user IDs to @mention directly (no lookup needed).
slack_conn_id: Airflow connection ID for the Slack API token
(defaults to Config.SLACK_DEFAULT_API = "slack_default_api")
Usage in a callback::
NotifySlackOperator(
task_id="slack_notify",
channel="#airflow",
text=":red_circle: *FAILED* | `my_dag`\\nTriggered by: Nhat Nguyen | duration: 4s",
mention_slack_emails=["nhat@ready.net"],
).execute(context)
"""
ui_color = "#4A154B" # Slack purple
template_fields = ("text",)
def __init__(
self,
channel: str,
text: str,
mention_slack_emails: list[str] | None = None,
mention_user_ids: list[str] | None = None,
slack_conn_id: str = Config.SLACK_DEFAULT_API,
**kwargs,
):
super().__init__(**kwargs)
self.channel = channel
self.text = text
self.mention_slack_emails = mention_slack_emails or []
self.mention_user_ids = mention_user_ids or []
self.slack_conn_id = slack_conn_id
[docs]
def execute(self, context):
hook = SlackHook(slack_conn_id=self.slack_conn_id)
# Start with directly-provided user IDs (no lookup needed)
mention_ids = list(self.mention_user_ids)
for email in self.mention_slack_emails:
# 1. Local SSO lookup — no API call, no OAuth scope required
uid = _get_slack_id(email)
if uid:
if uid not in mention_ids:
mention_ids.append(uid)
continue
# 2. Slack API fallback — requires users:read.email scope
try:
response = hook.client.users_lookupByEmail(email=email)
if response.get("ok"):
uid = response.get("user", {}).get("id")
if uid and uid not in mention_ids:
mention_ids.append(uid)
else:
self.log.warning(
"users.lookupByEmail failed for %s: %s", email, response.get("error")
)
except Exception as e:
self.log.warning("users.lookupByEmail exception for %s: %s", email, e)
mention_prefix = " ".join(f"<@{uid}>" for uid in mention_ids)
full_text = f"{mention_prefix}\n{self.text}".strip() if mention_prefix else self.text
hook.call(
"chat.postMessage",
json={"channel": self.channel, "text": full_text, "mrkdwn": True},
)