detectkit 0.58.0__tar.gz → 0.59.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {detectkit-0.58.0/detectkit.egg-info → detectkit-0.59.0}/PKG-INFO +2 -2
- {detectkit-0.58.0 → detectkit-0.59.0}/README.md +1 -1
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/__init__.py +1 -1
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/__init__.py +8 -0
- detectkit-0.59.0/detectkit/alerting/channels/discord.py +426 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/factory.py +8 -0
- detectkit-0.59.0/detectkit/alerting/channels/googlechat.py +392 -0
- detectkit-0.59.0/detectkit/alerting/channels/ntfy.py +305 -0
- detectkit-0.59.0/detectkit/alerting/channels/teams.py +329 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/alerting.md +72 -6
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/project.md +85 -1
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/init.py +36 -1
- {detectkit-0.58.0 → detectkit-0.59.0/detectkit.egg-info}/PKG-INFO +2 -2
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/SOURCES.txt +4 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/LICENSE +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/MANIFEST.in +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/axis_spec.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/ui.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/metric_io.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/core/models.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/autoreg.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/assets/tune.js +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/payload.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/assets/ui.js +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/html.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/jobs.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/metric_files.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/overview.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/ui/server.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/pyproject.toml +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/requirements.txt +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/setup.cfg +0 -0
- {detectkit-0.58.0 → detectkit-0.59.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detectkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.59.0
|
|
4
4
|
Summary: Metric monitoring with automatic anomaly detection
|
|
5
5
|
Author: detectkit team
|
|
6
6
|
License: MIT
|
|
@@ -95,7 +95,7 @@ Dynamic: license-file
|
|
|
95
95
|
- **Pure numpy arrays** — no pandas dependency in core logic
|
|
96
96
|
- **Statistical detectors** — Z-Score, MAD, IQR, Manual Bounds, and a prediction-based Autoreg (AR) detector for fast-moving, non-seasonal metrics
|
|
97
97
|
- **Trend & seasonality handling** — seasonality grouping, recency weighting (`half_life`), robust linear detrending for slowly drifting metrics
|
|
98
|
-
- **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Webhook
|
|
98
|
+
- **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Discord, Microsoft Teams, Google Chat, ntfy, Webhook (incl. a Rocket.Chat recipe)
|
|
99
99
|
- **@mentions** — tag users/groups in alerts, each channel formats natively
|
|
100
100
|
- **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
|
|
101
101
|
- **Project-level error alerts** — catch DB outages and pipeline crashes once per run
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- **Pure numpy arrays** — no pandas dependency in core logic
|
|
21
21
|
- **Statistical detectors** — Z-Score, MAD, IQR, Manual Bounds, and a prediction-based Autoreg (AR) detector for fast-moving, non-seasonal metrics
|
|
22
22
|
- **Trend & seasonality handling** — seasonality grouping, recency weighting (`half_life`), robust linear detrending for slowly drifting metrics
|
|
23
|
-
- **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Webhook
|
|
23
|
+
- **Multi-channel alerting** — Mattermost, Slack, Telegram, Email, Discord, Microsoft Teams, Google Chat, ntfy, Webhook (incl. a Rocket.Chat recipe)
|
|
24
24
|
- **@mentions** — tag users/groups in alerts, each channel formats natively
|
|
25
25
|
- **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
|
|
26
26
|
- **Project-level error alerts** — catch DB outages and pipeline crashes once per run
|
|
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
|
|
|
4
4
|
A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.59.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Alert channels for external notifications."""
|
|
2
2
|
|
|
3
3
|
from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
|
|
4
|
+
from detectkit.alerting.channels.discord import DiscordChannel
|
|
4
5
|
from detectkit.alerting.channels.email import EmailChannel
|
|
5
6
|
from detectkit.alerting.channels.factory import AlertChannelFactory
|
|
7
|
+
from detectkit.alerting.channels.googlechat import GoogleChatChannel
|
|
6
8
|
from detectkit.alerting.channels.mattermost import MattermostChannel
|
|
9
|
+
from detectkit.alerting.channels.ntfy import NtfyChannel
|
|
7
10
|
from detectkit.alerting.channels.slack import SlackChannel
|
|
11
|
+
from detectkit.alerting.channels.teams import TeamsChannel
|
|
8
12
|
from detectkit.alerting.channels.telegram import TelegramChannel
|
|
9
13
|
from detectkit.alerting.channels.webhook import WebhookChannel
|
|
10
14
|
|
|
@@ -16,5 +20,9 @@ __all__ = [
|
|
|
16
20
|
"SlackChannel",
|
|
17
21
|
"TelegramChannel",
|
|
18
22
|
"EmailChannel",
|
|
23
|
+
"DiscordChannel",
|
|
24
|
+
"TeamsChannel",
|
|
25
|
+
"GoogleChatChannel",
|
|
26
|
+
"NtfyChannel",
|
|
19
27
|
"AlertChannelFactory",
|
|
20
28
|
]
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discord incoming-webhook alert channel.
|
|
3
|
+
|
|
4
|
+
Sends alerts to a Discord channel via an "Execute Webhook" incoming webhook
|
|
5
|
+
URL (``https://discord.com/api/webhooks/<id>/<token>``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
|
|
15
|
+
from detectkit.alerting.channels.branding import BRAND_ICON_URL, BRAND_USERNAME
|
|
16
|
+
|
|
17
|
+
# Discord embed limits (https://discord.com/developers/docs/resources/message#embed-object).
|
|
18
|
+
# Enforced defensively so a verbose alert never triggers a 400 from Discord.
|
|
19
|
+
_TITLE_CAP = 256
|
|
20
|
+
_DESCRIPTION_CAP = 4096
|
|
21
|
+
_FIELD_NAME_CAP = 256
|
|
22
|
+
_FIELD_VALUE_CAP = 1024
|
|
23
|
+
_FOOTER_CAP = 2048
|
|
24
|
+
# Detector params are appended to the description as a fenced block; capped on
|
|
25
|
+
# their own, and the whole block is dropped if it would push the description
|
|
26
|
+
# past this budget (well under the hard 4096 cap, leaving headroom for the
|
|
27
|
+
# lead/rule/value/links sections that precede it).
|
|
28
|
+
_PARAMS_CAP = 900
|
|
29
|
+
_PARAMS_DESC_BUDGET = 3500
|
|
30
|
+
# Top-level message content (the mentions line) has its own 2000-char limit,
|
|
31
|
+
# and the SUM of title + description + field names/values + footer text across
|
|
32
|
+
# the embed must stay under 6000 or Discord rejects the whole message.
|
|
33
|
+
_CONTENT_CAP = 2000
|
|
34
|
+
_EMBED_TOTAL_CAP = 6000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DiscordChannel(BaseAlertChannel):
|
|
38
|
+
"""
|
|
39
|
+
Discord alert channel using an incoming webhook.
|
|
40
|
+
|
|
41
|
+
Sends one **embed** per alert via Discord's "Execute Webhook" endpoint.
|
|
42
|
+
Discord embeds have no "Show more" fold (unlike Slack/Mattermost
|
|
43
|
+
attachments), so the verbose evidence that ``WebhookChannel`` collapses
|
|
44
|
+
behind a fold instead rides in a compact inline **field grid** — Quorum /
|
|
45
|
+
Severity / the anomalous span / Detectors for an anomaly, the incident
|
|
46
|
+
timeline for a recovery. No-data and error alerts stay short (no fields).
|
|
47
|
+
|
|
48
|
+
The default (no custom ``template``) embed body mirrors
|
|
49
|
+
``WebhookChannel._build_rich_attachments``' section order: the lead
|
|
50
|
+
sentence + a **Rule** chip, then **Value** / **Expected** plus a compact
|
|
51
|
+
**Links** line (Dashboard, any extra links, then the "how to read this
|
|
52
|
+
alert" guide — clickable labels, never raw URLs). Detector parameters
|
|
53
|
+
(anomaly only) are appended as a fenced code block, capped and dropped
|
|
54
|
+
entirely if they would blow the description budget.
|
|
55
|
+
|
|
56
|
+
Mentions ride in the top-level ``content`` field — Discord never delivers
|
|
57
|
+
a ping placed inside an embed — paired with an ``allowed_mentions`` object
|
|
58
|
+
so ``@everyone``/``@here``/role mentions actually notify.
|
|
59
|
+
|
|
60
|
+
Parameters:
|
|
61
|
+
webhook_url (str): Discord webhook URL
|
|
62
|
+
(``https://discord.com/api/webhooks/<id>/<token>``).
|
|
63
|
+
username (str): Bot username override (default: the detectkit brand
|
|
64
|
+
name).
|
|
65
|
+
avatar_url (str): Bot avatar image URL override (default: the
|
|
66
|
+
detectkit brand avatar).
|
|
67
|
+
timeout (int): Request timeout in seconds (default: 10).
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
>>> channel = DiscordChannel(
|
|
71
|
+
... webhook_url="https://discord.com/api/webhooks/123/abc"
|
|
72
|
+
... )
|
|
73
|
+
>>> channel.send(alert_data)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
webhook_url: str,
|
|
79
|
+
username: str = BRAND_USERNAME,
|
|
80
|
+
avatar_url: str | None = None,
|
|
81
|
+
timeout: int = 10,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Initialize Discord channel."""
|
|
84
|
+
if not webhook_url:
|
|
85
|
+
raise ValueError("webhook_url is required")
|
|
86
|
+
|
|
87
|
+
self.webhook_url = webhook_url
|
|
88
|
+
self.username = username
|
|
89
|
+
# Default to the detectkit brand avatar; an explicit avatar_url opts out.
|
|
90
|
+
self.avatar_url = avatar_url if avatar_url is not None else BRAND_ICON_URL
|
|
91
|
+
self.timeout = timeout
|
|
92
|
+
|
|
93
|
+
def send(
|
|
94
|
+
self,
|
|
95
|
+
alert_data: AlertData,
|
|
96
|
+
template: str | None = None,
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""
|
|
99
|
+
Send alert to Discord.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
alert_data: Alert data to send.
|
|
103
|
+
template: Optional custom message template. Renders as a single
|
|
104
|
+
plain embed (color/title/footer/timestamp kept, no field
|
|
105
|
+
grid) with the formatted template as the description.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if sent successfully, False otherwise.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> channel = DiscordChannel(webhook_url="https://...")
|
|
112
|
+
>>> success = channel.send(alert_data)
|
|
113
|
+
"""
|
|
114
|
+
if template is not None:
|
|
115
|
+
embed = self._build_template_embed(alert_data, template)
|
|
116
|
+
else:
|
|
117
|
+
embed = self._build_embed(alert_data)
|
|
118
|
+
|
|
119
|
+
payload: dict[str, Any] = {
|
|
120
|
+
"username": self.username,
|
|
121
|
+
"avatar_url": self.avatar_url,
|
|
122
|
+
"embeds": [embed],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Mentions inside an embed never ping on Discord — they must ride in
|
|
126
|
+
# the top-level message content, paired with allowed_mentions so the
|
|
127
|
+
# platform actually delivers the ping instead of just rendering text.
|
|
128
|
+
mentions = self.format_mentions(alert_data.mentions)
|
|
129
|
+
if mentions:
|
|
130
|
+
payload["content"] = self._cap(mentions, _CONTENT_CAP)
|
|
131
|
+
payload["allowed_mentions"] = {"parse": ["everyone", "users", "roles"]}
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
response = requests.post(self.webhook_url, json=payload, timeout=self.timeout)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
return True
|
|
137
|
+
except requests.RequestException as e:
|
|
138
|
+
print(f"Failed to send Discord alert: {e}")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Embed construction
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
def _build_embed(self, alert_data: AlertData) -> dict[str, Any]:
|
|
145
|
+
"""Build the default rich embed for *alert_data*."""
|
|
146
|
+
ctx = self.build_context(alert_data)
|
|
147
|
+
kind = self.status_kind(alert_data)
|
|
148
|
+
|
|
149
|
+
description = self._build_description(alert_data, ctx, kind)
|
|
150
|
+
description = self._append_params(description, ctx, kind)
|
|
151
|
+
description = self._cap_block(description, _DESCRIPTION_CAP)
|
|
152
|
+
|
|
153
|
+
title = self._cap(self.format_title(alert_data), _TITLE_CAP)
|
|
154
|
+
footer = self._footer(alert_data)
|
|
155
|
+
fields = self._fields(alert_data, ctx, kind)
|
|
156
|
+
|
|
157
|
+
# Discord also enforces a 6000-char cap on the SUM of title +
|
|
158
|
+
# description + all field names/values + footer text; the description
|
|
159
|
+
# is the biggest block, so it absorbs any overflow.
|
|
160
|
+
overhead = (
|
|
161
|
+
len(title) + len(footer["text"]) + sum(len(f["name"]) + len(f["value"]) for f in fields)
|
|
162
|
+
)
|
|
163
|
+
if overhead + len(description) > _EMBED_TOTAL_CAP:
|
|
164
|
+
description = self._cap_block(description, max(_EMBED_TOTAL_CAP - overhead, 64))
|
|
165
|
+
|
|
166
|
+
embed: dict[str, Any] = {
|
|
167
|
+
"color": self._color_int(alert_data),
|
|
168
|
+
"title": title,
|
|
169
|
+
"description": description,
|
|
170
|
+
"footer": footer,
|
|
171
|
+
}
|
|
172
|
+
if alert_data.dashboard_url:
|
|
173
|
+
embed["url"] = alert_data.dashboard_url
|
|
174
|
+
timestamp = self._iso_utc(alert_data.timestamp)
|
|
175
|
+
if timestamp is not None:
|
|
176
|
+
embed["timestamp"] = timestamp
|
|
177
|
+
|
|
178
|
+
if fields:
|
|
179
|
+
embed["fields"] = fields
|
|
180
|
+
|
|
181
|
+
return embed
|
|
182
|
+
|
|
183
|
+
def _build_template_embed(self, alert_data: AlertData, template: str) -> dict[str, Any]:
|
|
184
|
+
"""Build the plain embed used for a custom ``template`` (no fields)."""
|
|
185
|
+
title = self._cap(self.format_title(alert_data), _TITLE_CAP)
|
|
186
|
+
footer = self._footer(alert_data)
|
|
187
|
+
# Same 6000-char embed-total guard as the rich embed (title + footer
|
|
188
|
+
# alone can leave less than the 4096 description cap).
|
|
189
|
+
budget = min(_DESCRIPTION_CAP, _EMBED_TOTAL_CAP - len(title) - len(footer["text"]))
|
|
190
|
+
embed: dict[str, Any] = {
|
|
191
|
+
"color": self._color_int(alert_data),
|
|
192
|
+
"title": title,
|
|
193
|
+
"description": self._cap_block(self.format_message(alert_data, template), budget),
|
|
194
|
+
"footer": footer,
|
|
195
|
+
}
|
|
196
|
+
if alert_data.dashboard_url:
|
|
197
|
+
embed["url"] = alert_data.dashboard_url
|
|
198
|
+
timestamp = self._iso_utc(alert_data.timestamp)
|
|
199
|
+
if timestamp is not None:
|
|
200
|
+
embed["timestamp"] = timestamp
|
|
201
|
+
return embed
|
|
202
|
+
|
|
203
|
+
def _build_description(
|
|
204
|
+
self,
|
|
205
|
+
alert_data: AlertData,
|
|
206
|
+
ctx: dict[str, Any],
|
|
207
|
+
kind: str,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""The CommonMark description body, blank-line-separated sections
|
|
210
|
+
mirroring ``WebhookChannel._build_rich_attachments``'s section order:
|
|
211
|
+
(1) the kind lead + the **Rule** chip; (2) Value/Expected (anomaly and
|
|
212
|
+
recovery only) + the compact Links line. The verbose evidence tail
|
|
213
|
+
that the webhook attachment folds moves to :meth:`_fields` instead —
|
|
214
|
+
Discord embeds have no fold.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def bold_code(label: str, value: str) -> str:
|
|
218
|
+
return f"**{label}** `{value}`" if value else ""
|
|
219
|
+
|
|
220
|
+
links_line = self._links_line(alert_data, ctx)
|
|
221
|
+
|
|
222
|
+
sections: list[list[str]] = []
|
|
223
|
+
if kind == "anomaly":
|
|
224
|
+
sections.append([ctx["anomaly_lead"], f"**Rule** `{ctx['rule_display']}`"])
|
|
225
|
+
sections.append(
|
|
226
|
+
[
|
|
227
|
+
f"{bold_code('Value', ctx['value_display'])} · "
|
|
228
|
+
f"{bold_code('Expected', ctx['expected_range'])}",
|
|
229
|
+
links_line,
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
elif kind == "recovery":
|
|
233
|
+
sections.append([ctx["recovery_lead"], f"**Rule** `{ctx['rule_display']}`"])
|
|
234
|
+
sections.append(
|
|
235
|
+
[
|
|
236
|
+
f"{bold_code('Value', ctx['value_display'])} · "
|
|
237
|
+
f"{bold_code('Expected', ctx['expected_range'])}",
|
|
238
|
+
links_line,
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
elif kind == "no_data":
|
|
242
|
+
sections.append(["Query returned no datapoint for the latest expected interval."])
|
|
243
|
+
sections.append(
|
|
244
|
+
[
|
|
245
|
+
bold_code("Expected at", ctx["timestamp"]),
|
|
246
|
+
bold_code("Expected", ctx["expected_range"]),
|
|
247
|
+
links_line,
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
else: # error
|
|
251
|
+
err = f"{ctx['error_type']}: {ctx['error_message']}".strip(": ")
|
|
252
|
+
sections.append(["The detectkit pipeline failed for this metric."])
|
|
253
|
+
sections.append(
|
|
254
|
+
[
|
|
255
|
+
bold_code("Detected at", ctx["timestamp"]),
|
|
256
|
+
bold_code("Error", err),
|
|
257
|
+
links_line,
|
|
258
|
+
]
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return "\n\n".join(
|
|
262
|
+
"\n".join(entry for entry in section if entry)
|
|
263
|
+
for section in sections
|
|
264
|
+
if any(entry for entry in section)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _links_line(alert_data: AlertData, ctx: dict[str, Any]) -> str:
|
|
269
|
+
""" "**Links** [Dashboard](url) · [label](url) · [How to read this
|
|
270
|
+
alert](help)" — clickable labels, never raw URLs (empty when none)."""
|
|
271
|
+
parts: list[str] = []
|
|
272
|
+
if alert_data.dashboard_url:
|
|
273
|
+
parts.append(f"[Dashboard]({alert_data.dashboard_url})")
|
|
274
|
+
for label, url in alert_data.links.items():
|
|
275
|
+
parts.append(f"[{label}]({url})")
|
|
276
|
+
if ctx["help_url"]:
|
|
277
|
+
parts.append(f"[{ctx['help_label']}]({ctx['help_url']})")
|
|
278
|
+
if not parts:
|
|
279
|
+
return ""
|
|
280
|
+
return "**Links** " + " · ".join(parts)
|
|
281
|
+
|
|
282
|
+
def _append_params(self, description: str, ctx: dict[str, Any], kind: str) -> str:
|
|
283
|
+
"""Append the fenced detector-params block (anomaly only), dropping it
|
|
284
|
+
entirely rather than truncating the description mid-JSON if it would
|
|
285
|
+
push the body past the budget."""
|
|
286
|
+
if kind != "anomaly" or not ctx["detector_params"]:
|
|
287
|
+
return description
|
|
288
|
+
params = self._cap(ctx["detector_params"], _PARAMS_CAP)
|
|
289
|
+
candidate = f"{description}\n\n**Parameters**\n```\n{params}\n```"
|
|
290
|
+
if len(candidate) <= _PARAMS_DESC_BUDGET:
|
|
291
|
+
return candidate
|
|
292
|
+
return description
|
|
293
|
+
|
|
294
|
+
def _fields(
|
|
295
|
+
self,
|
|
296
|
+
alert_data: AlertData,
|
|
297
|
+
ctx: dict[str, Any],
|
|
298
|
+
kind: str,
|
|
299
|
+
) -> list[dict[str, Any]]:
|
|
300
|
+
"""The inline field grid carrying the verbose tail — empty for
|
|
301
|
+
no_data/error (their short body has no separate tail to move)."""
|
|
302
|
+
|
|
303
|
+
def field(name: str, value: str) -> dict[str, Any]:
|
|
304
|
+
return {
|
|
305
|
+
"name": self._cap(name, _FIELD_NAME_CAP),
|
|
306
|
+
"value": self._cap(value, _FIELD_VALUE_CAP),
|
|
307
|
+
"inline": True,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fields: list[dict[str, Any]] = []
|
|
311
|
+
if kind == "anomaly":
|
|
312
|
+
fields.append(
|
|
313
|
+
field(
|
|
314
|
+
"Quorum",
|
|
315
|
+
f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
fields.append(field("Severity", f"{alert_data.severity:.2f}"))
|
|
319
|
+
if ctx["started_display"]:
|
|
320
|
+
fields.append(field("Anomaly began", ctx["started_display"]))
|
|
321
|
+
fields.append(field("Latest reading", ctx["timestamp"]))
|
|
322
|
+
else:
|
|
323
|
+
fields.append(field("Detected at", ctx["timestamp"]))
|
|
324
|
+
fields.append(field("Detectors", ctx["detector_name"]))
|
|
325
|
+
elif kind == "recovery":
|
|
326
|
+
if ctx["started_display"]:
|
|
327
|
+
fields.append(field("Anomaly began", ctx["started_display"]))
|
|
328
|
+
if ctx["fired_display"]:
|
|
329
|
+
fields.append(field("Alert fired", ctx["fired_display"]))
|
|
330
|
+
fields.append(field("Recovered", ctx["timestamp"]))
|
|
331
|
+
else:
|
|
332
|
+
fields.append(field("Cleared at", ctx["timestamp"]))
|
|
333
|
+
fields.append(field("Detectors", ctx["detector_name"]))
|
|
334
|
+
return fields
|
|
335
|
+
|
|
336
|
+
def _footer(self, alert_data: AlertData) -> dict[str, Any]:
|
|
337
|
+
"""Branded footer, paired with the project name when set (mirrors
|
|
338
|
+
``WebhookChannel``'s "detectkit · <project>" footer)."""
|
|
339
|
+
text = self.username or BRAND_USERNAME
|
|
340
|
+
if alert_data.project_name:
|
|
341
|
+
text = f"{text} · {alert_data.project_name}"
|
|
342
|
+
footer: dict[str, Any] = {"text": self._cap(text, _FOOTER_CAP)}
|
|
343
|
+
if self.avatar_url:
|
|
344
|
+
footer["icon_url"] = self.avatar_url
|
|
345
|
+
return footer
|
|
346
|
+
|
|
347
|
+
def _color_int(self, alert_data: AlertData) -> int:
|
|
348
|
+
"""The status accent color as the integer Discord embeds expect."""
|
|
349
|
+
return int(self.status_color(alert_data).lstrip("#"), 16)
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _cap(value: str, limit: int) -> str:
|
|
353
|
+
"""Truncate *value* to *limit* chars with an ellipsis."""
|
|
354
|
+
if len(value) <= limit:
|
|
355
|
+
return value
|
|
356
|
+
return value[: limit - 1] + "…"
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def _cap_block(cls, value: str, limit: int) -> str:
|
|
360
|
+
"""Truncate a multi-line block to *limit* chars, cutting at the last
|
|
361
|
+
line break before the limit when there is one — so a markdown link
|
|
362
|
+
(``[label](https://…)``) is dropped whole, never sliced mid-URL into
|
|
363
|
+
broken markup."""
|
|
364
|
+
if len(value) <= limit:
|
|
365
|
+
return value
|
|
366
|
+
cut = value.rfind("\n", 0, limit - 2)
|
|
367
|
+
if cut > 0:
|
|
368
|
+
return value[:cut].rstrip() + "\n…"
|
|
369
|
+
return cls._cap(value, limit)
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _iso_utc(ts: Any) -> str | None:
|
|
373
|
+
"""ISO-8601 UTC string (``2026-07-11T10:30:00Z``) for a naive-UTC
|
|
374
|
+
timestamp, or ``None`` when unset/unparseable. Mirrors
|
|
375
|
+
``WebhookChannel._iso_utc``."""
|
|
376
|
+
if ts is None:
|
|
377
|
+
return None
|
|
378
|
+
try:
|
|
379
|
+
if isinstance(ts, np.datetime64):
|
|
380
|
+
ts = ts.astype("datetime64[s]").astype(datetime)
|
|
381
|
+
if isinstance(ts, datetime):
|
|
382
|
+
if ts.tzinfo is not None:
|
|
383
|
+
ts = ts.astimezone(timezone.utc).replace(tzinfo=None)
|
|
384
|
+
return str(ts.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
385
|
+
except (ValueError, TypeError, OverflowError):
|
|
386
|
+
return None
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
def format_mentions(self, mentions: list[str]) -> str:
|
|
390
|
+
"""
|
|
391
|
+
Format mentions into Discord's native ping syntax.
|
|
392
|
+
|
|
393
|
+
``"all"``/``"everyone"``/``"channel"`` map to ``@everyone``,
|
|
394
|
+
``"here"`` to ``@here``. A value already shaped like a real Discord
|
|
395
|
+
mention (``<@user_id>``/``<@&role_id>``) passes through verbatim.
|
|
396
|
+
Anything else renders as a bare ``@name`` — bare names render but
|
|
397
|
+
don't actually ping; a real user/role ping needs the ``<@id>`` form,
|
|
398
|
+
which can be put directly in the ``mentions`` list.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
mentions: List of usernames, role/user mentions, or the special
|
|
402
|
+
keywords ``"channel"``/``"all"``/``"everyone"``/``"here"``.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Space-joined Discord mention string.
|
|
406
|
+
"""
|
|
407
|
+
if not mentions:
|
|
408
|
+
return ""
|
|
409
|
+
parts: list[str] = []
|
|
410
|
+
for m in mentions:
|
|
411
|
+
if m in ("all", "everyone", "channel"):
|
|
412
|
+
parts.append("@everyone")
|
|
413
|
+
elif m == "here":
|
|
414
|
+
parts.append("@here")
|
|
415
|
+
elif m.startswith("<@") and m.endswith(">"):
|
|
416
|
+
parts.append(m)
|
|
417
|
+
else:
|
|
418
|
+
parts.append(f"@{m}")
|
|
419
|
+
return " ".join(parts)
|
|
420
|
+
|
|
421
|
+
def __repr__(self) -> str:
|
|
422
|
+
"""String representation."""
|
|
423
|
+
url_preview = (
|
|
424
|
+
self.webhook_url[:30] + "..." if len(self.webhook_url) > 30 else self.webhook_url
|
|
425
|
+
)
|
|
426
|
+
return f"DiscordChannel(url='{url_preview}', username='{self.username}')"
|
|
@@ -3,9 +3,13 @@ Alert channel factory for creating channel instances from configuration.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from detectkit.alerting.channels.base import BaseAlertChannel
|
|
6
|
+
from detectkit.alerting.channels.discord import DiscordChannel
|
|
6
7
|
from detectkit.alerting.channels.email import EmailChannel
|
|
8
|
+
from detectkit.alerting.channels.googlechat import GoogleChatChannel
|
|
7
9
|
from detectkit.alerting.channels.mattermost import MattermostChannel
|
|
10
|
+
from detectkit.alerting.channels.ntfy import NtfyChannel
|
|
8
11
|
from detectkit.alerting.channels.slack import SlackChannel
|
|
12
|
+
from detectkit.alerting.channels.teams import TeamsChannel
|
|
9
13
|
from detectkit.alerting.channels.telegram import TelegramChannel
|
|
10
14
|
from detectkit.alerting.channels.webhook import WebhookChannel
|
|
11
15
|
from detectkit.utils.env_interpolation import interpolate_env_vars
|
|
@@ -31,6 +35,10 @@ class AlertChannelFactory:
|
|
|
31
35
|
"slack": SlackChannel,
|
|
32
36
|
"telegram": TelegramChannel,
|
|
33
37
|
"email": EmailChannel,
|
|
38
|
+
"discord": DiscordChannel,
|
|
39
|
+
"teams": TeamsChannel,
|
|
40
|
+
"googlechat": GoogleChatChannel,
|
|
41
|
+
"ntfy": NtfyChannel,
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
@classmethod
|