diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py
index 6a9062bdd4..0a52d01d09 100644
--- a/engine/apps/alerts/models/alert_group.py
+++ b/engine/apps/alerts/models/alert_group.py
@@ -50,6 +50,7 @@
)
from apps.base.models import UserNotificationPolicyLogRecord
from apps.labels.models import AlertGroupAssociatedLabel
+ from apps.mattermost.models import MattermostMessage
from apps.slack.models import SlackMessage
logger = logging.getLogger(__name__)
@@ -1989,6 +1990,10 @@ def slack_message(self) -> typing.Optional["SlackMessage"]:
except AttributeError:
return self.slack_messages.order_by("created_at").first()
+ @property
+ def mattermost_message(self) -> typing.Optional["MattermostMessage"]:
+ return self.mattermost_messages.order_by("created_at").first()
+
@cached_property
def last_stop_escalation_log(self):
from apps.alerts.models import AlertGroupLogRecord
diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py
index 32bdeadb93..5921f4bf58 100644
--- a/engine/apps/base/models/live_setting.py
+++ b/engine/apps/base/models/live_setting.py
@@ -85,6 +85,7 @@ class LiveSetting(models.Model):
"MATTERMOST_HOST",
"MATTERMOST_BOT_TOKEN",
"MATTERMOST_LOGIN_RETURN_REDIRECT_HOST",
+ "MATTERMOST_SIGNING_SECRET",
)
DESCRIPTIONS = {
@@ -217,6 +218,11 @@ class LiveSetting(models.Model):
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction for details how to set up Mattermost. "
),
+ "MATTERMOST_SIGNING_SECRET": (
+ "Check instruction for details how to set up Mattermost. "
+ ),
}
SECRET_SETTING_NAMES = (
diff --git a/engine/apps/mattermost/alert_group_representative.py b/engine/apps/mattermost/alert_group_representative.py
new file mode 100644
index 0000000000..d653619b3c
--- /dev/null
+++ b/engine/apps/mattermost/alert_group_representative.py
@@ -0,0 +1,87 @@
+import logging
+
+from rest_framework import status
+
+from apps.alerts.models import AlertGroup
+from apps.alerts.representative import AlertGroupAbstractRepresentative
+from apps.mattermost.alert_rendering import MattermostMessageRenderer
+from apps.mattermost.client import MattermostClient
+from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
+from apps.mattermost.tasks import on_alert_group_action_triggered_async, on_create_alert_async
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+class AlertGroupMattermostRepresentative(AlertGroupAbstractRepresentative):
+ def __init__(self, log_record) -> None:
+ self.log_record = log_record
+
+ def is_applicable(self):
+ from apps.mattermost.models import MattermostChannel
+
+ organization = self.log_record.alert_group.channel.organization
+ handler_exists = self.log_record.type in self.get_handler_map().keys()
+
+ mattermost_channels = MattermostChannel.objects.filter(organization=organization)
+ return handler_exists and mattermost_channels.exists()
+
+ @staticmethod
+ def get_handler_map():
+ from apps.alerts.models import AlertGroupLogRecord
+
+ return {
+ AlertGroupLogRecord.TYPE_ACK: "alert_group_action",
+ AlertGroupLogRecord.TYPE_UN_ACK: "alert_group_action",
+ AlertGroupLogRecord.TYPE_AUTO_UN_ACK: "alert_group_action",
+ AlertGroupLogRecord.TYPE_RESOLVED: "alert_group_action",
+ AlertGroupLogRecord.TYPE_UN_RESOLVED: "alert_group_action",
+ AlertGroupLogRecord.TYPE_ACK_REMINDER_TRIGGERED: "alert_group_action",
+ AlertGroupLogRecord.TYPE_SILENCE: "alert_group_action",
+ AlertGroupLogRecord.TYPE_UN_SILENCE: "alert_group_action",
+ AlertGroupLogRecord.TYPE_ATTACHED: "alert_group_action",
+ AlertGroupLogRecord.TYPE_UNATTACHED: "alert_group_action",
+ }
+
+ def on_alert_group_action(self, alert_group: AlertGroup):
+ logger.info(f"Update mattermost message for alert_group {alert_group.pk}")
+ payload = MattermostMessageRenderer(alert_group).render_alert_group_message()
+ mattermost_message = alert_group.mattermost_message
+ try:
+ client = MattermostClient()
+ client.update_post(post_id=mattermost_message.post_id, data=payload)
+ except MattermostAPITokenInvalid:
+ logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group.pk}")
+ except MattermostAPIException as ex:
+ logger.error(f"Mattermost API error {ex}")
+ if ex.status not in [status.HTTP_401_UNAUTHORIZED]:
+ raise ex
+
+ @staticmethod
+ def on_create_alert(**kwargs):
+ alert_pk = kwargs["alert"]
+ on_create_alert_async.apply_async((alert_pk,))
+
+ @staticmethod
+ def on_alert_group_action_triggered(**kwargs):
+ from apps.alerts.models import AlertGroupLogRecord
+
+ log_record = kwargs["log_record"]
+ if isinstance(log_record, AlertGroupLogRecord):
+ log_record_id = log_record.pk
+ else:
+ log_record_id = log_record
+ on_alert_group_action_triggered_async.apply_async((log_record_id,))
+
+ def get_handler(self):
+ handler_name = self.get_handler_name()
+ logger.info(f"Using '{handler_name}' handler to process alert action in mattermost")
+ if hasattr(self, handler_name):
+ handler = getattr(self, handler_name)
+ else:
+ handler = None
+
+ return handler
+
+ def get_handler_name(self):
+ return self.HANDLER_PREFIX + self.get_handler_map()[self.log_record.type]
diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py
new file mode 100644
index 0000000000..af0d81593a
--- /dev/null
+++ b/engine/apps/mattermost/alert_rendering.py
@@ -0,0 +1,128 @@
+from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
+from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
+from apps.alerts.models import Alert, AlertGroup
+from apps.mattermost.utils import MattermostEventAuthenticator
+from common.api_helpers.utils import create_engine_url
+from common.utils import is_string_with_visible_characters, str_or_backup
+
+
+class MattermostMessageRenderer:
+ def __init__(self, alert_group: AlertGroup):
+ self.alert_group = alert_group
+
+ def render_alert_group_message(self):
+ attachments = AlertGroupMattermostRenderer(self.alert_group).render_alert_group_attachments()
+ return {"props": {"attachments": attachments}}
+
+
+class AlertMattermostTemplater(AlertTemplater):
+ RENDER_FOR_MATTERMOST = "mattermost"
+
+ def _render_for(self) -> str:
+ return self.RENDER_FOR_MATTERMOST
+
+
+class AlertMattermostRenderer(AlertBaseRenderer):
+ def __init__(self, alert: Alert):
+ super().__init__(alert)
+ self.channel = alert.group.channel
+
+ @property
+ def templater_class(self):
+ return AlertMattermostTemplater
+
+ def render_alert_attachments(self):
+ attachments = []
+ title = str_or_backup(self.templated_alert.title, "Alert")
+ message = ""
+ if is_string_with_visible_characters(self.templated_alert.message):
+ message = self.templated_alert.message
+ attachments.append(
+ {
+ "fallback": "{}: {}".format(self.channel.get_integration_display(), self.alert.title),
+ "title": title,
+ "title_link": self.templated_alert.source_link,
+ "text": message,
+ "image_url": self.templated_alert.image_url,
+ }
+ )
+ return attachments
+
+
+class AlertGroupMattermostRenderer(AlertGroupBaseRenderer):
+ def __init__(self, alert_group: AlertGroup):
+ super().__init__(alert_group)
+
+ self.alert_renderer = self.alert_renderer_class(self.alert_group.alerts.last())
+
+ @property
+ def alert_renderer_class(self):
+ return AlertMattermostRenderer
+
+ def render_alert_group_attachments(self):
+ attachments = self.alert_renderer.render_alert_attachments()
+ alert_group = self.alert_group
+
+ if alert_group.resolved:
+ attachments.append(
+ {
+ "fallback": "Resolved...",
+ "text": alert_group.get_resolve_text(),
+ }
+ )
+ elif alert_group.acknowledged:
+ attachments.append(
+ {
+ "fallback": "Acknowledged...",
+ "text": alert_group.get_acknowledge_text(),
+ }
+ )
+
+ # append buttons to the initial attachment
+ attachments[0]["actions"] = self._get_buttons_attachments()
+
+ return self._set_attachments_color(attachments)
+
+ def _get_buttons_attachments(self):
+ actions = []
+
+ def _make_actions(id, name, token):
+ return {
+ "id": id,
+ "name": name,
+ "integration": {
+ "url": create_engine_url("api/internal/v1/mattermost/event/"),
+ "context": {
+ "action": id,
+ "token": token,
+ },
+ },
+ }
+
+ token = MattermostEventAuthenticator.create_token(organization=self.alert_group.channel.organization)
+ if not self.alert_group.resolved:
+ if self.alert_group.acknowledged:
+ actions.append(_make_actions("unacknowledge", "Unacknowledge", token))
+ else:
+ actions.append(_make_actions("acknowledge", "Acknowledge", token))
+
+ if self.alert_group.resolved:
+ actions.append(_make_actions("unresolve", "Unresolve", token))
+ else:
+ actions.append(_make_actions("resolve", "Resolve", token))
+
+ return actions
+
+ def _set_attachments_color(self, attachments):
+ color = "#a30200" # danger
+ if self.alert_group.silenced:
+ color = "#dddddd" # slack-grey
+ if self.alert_group.acknowledged:
+ color = "#daa038" # warning
+ if self.alert_group.resolved:
+ color = "#2eb886" # good
+
+ for attachment in attachments:
+ attachment["color"] = color
+
+ return attachments
diff --git a/engine/apps/mattermost/apps.py b/engine/apps/mattermost/apps.py
index 7a6fc4ec38..841c7fc88f 100644
--- a/engine/apps/mattermost/apps.py
+++ b/engine/apps/mattermost/apps.py
@@ -3,3 +3,6 @@
class MattermostConfig(AppConfig):
name = "apps.mattermost"
+
+ def ready(self) -> None:
+ import apps.mattermost.signals # noqa: F401
diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py
index 5a3c001168..ac789f50a4 100644
--- a/engine/apps/mattermost/client.py
+++ b/engine/apps/mattermost/client.py
@@ -1,3 +1,4 @@
+import json
from dataclasses import dataclass
from typing import Optional
@@ -33,6 +34,13 @@ class MattermostChannel:
display_name: str
+@dataclass
+class MattermostPost:
+ post_id: str
+ channel_id: str
+ user_id: str
+
+
class MattermostClient:
def __init__(self, token: Optional[str] = None) -> None:
self.token = token or settings.MATTERMOST_BOT_TOKEN
@@ -82,3 +90,19 @@ def get_user(self, user_id: str = "me"):
self._check_response(response)
data = response.json()
return MattermostUser(user_id=data["id"], username=data["username"], nickname=data["nickname"])
+
+ def create_post(self, channel_id: str, data: dict):
+ url = f"{self.base_url}/posts"
+ data.update({"channel_id": channel_id})
+ response = requests.post(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
+ self._check_response(response)
+ data = response.json()
+ return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])
+
+ def update_post(self, post_id: str, data: dict):
+ url = f"{self.base_url}/posts/{post_id}"
+ data.update({"id": post_id})
+ response = requests.put(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
+ self._check_response(response)
+ data = response.json()
+ return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])
diff --git a/engine/apps/mattermost/exceptions.py b/engine/apps/mattermost/exceptions.py
index 5df5a56f38..a96b90c993 100644
--- a/engine/apps/mattermost/exceptions.py
+++ b/engine/apps/mattermost/exceptions.py
@@ -11,3 +11,11 @@ def __init__(self, status, url, msg="", method="GET"):
def __str__(self) -> str:
return f"MattermostAPIException: status={self.status} url={self.url} method={self.method} error={self.msg}"
+
+
+class MattermostEventTokenInvalid(Exception):
+ def __init__(self, msg=""):
+ self.msg = msg
+
+ def __str__(self):
+ return f"MattermostEventTokenInvalid message={self.msg}"
diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py
index 2601961c6d..da5e82a0a6 100644
--- a/engine/apps/mattermost/migrations/0001_initial.py
+++ b/engine/apps/mattermost/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.15 on 2024-10-07 02:12
+# Generated by Django 4.2.15 on 2024-10-15 05:17
import apps.mattermost.models.channel
import django.core.validators
@@ -12,6 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
('user_management', '0022_alter_team_unique_together'),
+ ('alerts', '0060_relatedincident'),
]
operations = [
@@ -23,7 +24,18 @@ class Migration(migrations.Migration):
('username', models.CharField(max_length=100)),
('nickname', models.CharField(blank=True, default=None, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
- ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_connection', to='user_management.user')),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_user_identity', to='user_management.user')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='MattermostMessage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('post_id', models.CharField(max_length=100)),
+ ('channel_id', models.CharField(max_length=100)),
+ ('message_type', models.IntegerField(choices=[(0, 'Alert group message'), (1, 'Log message')])),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_messages', to='alerts.alertgroup')),
],
),
migrations.CreateModel(
diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py
index 0cf4dd7f73..cada7d9f23 100644
--- a/engine/apps/mattermost/models/__init__.py
+++ b/engine/apps/mattermost/models/__init__.py
@@ -1,2 +1,3 @@
from .channel import MattermostChannel # noqa: F401
+from .message import MattermostMessage # noqa F401
from .user import MattermostUser # noqa F401
diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py
index f901b814eb..a8ea351c94 100644
--- a/engine/apps/mattermost/models/channel.py
+++ b/engine/apps/mattermost/models/channel.py
@@ -1,7 +1,10 @@
+import typing
+
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models, transaction
+from apps.alerts.models import AlertGroup
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@@ -44,6 +47,14 @@ class MattermostChannel(models.Model):
class Meta:
unique_together = ("organization", "channel_id")
+ @classmethod
+ def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional["MattermostChannel"]:
+ default_channel = cls.objects.filter(
+ organization=alert_group.channel.organization, is_default_channel=True
+ ).first()
+
+ return default_channel
+
def make_channel_default(self, author):
try:
old_default_channel = MattermostChannel.objects.get(organization=self.organization, is_default_channel=True)
diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py
new file mode 100644
index 0000000000..e41713fd57
--- /dev/null
+++ b/engine/apps/mattermost/models/message.py
@@ -0,0 +1,33 @@
+from django.db import models
+
+from apps.alerts.models import AlertGroup
+from apps.mattermost.client import MattermostPost
+
+
+class MattermostMessage(models.Model):
+ (
+ ALERT_GROUP_MESSAGE,
+ LOG_MESSAGE,
+ ) = range(2)
+
+ MATTERMOST_MESSAGE_CHOICES = ((ALERT_GROUP_MESSAGE, "Alert group message"), (LOG_MESSAGE, "Log message"))
+
+ post_id = models.CharField(max_length=100)
+
+ channel_id = models.CharField(max_length=100)
+
+ message_type = models.IntegerField(choices=MATTERMOST_MESSAGE_CHOICES)
+
+ alert_group = models.ForeignKey(
+ "alerts.AlertGroup",
+ on_delete=models.CASCADE,
+ related_name="mattermost_messages",
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ @staticmethod
+ def create_message(alert_group: AlertGroup, post: MattermostPost, message_type: int):
+ return MattermostMessage.objects.create(
+ alert_group=alert_group, post_id=post.post_id, channel_id=post.channel_id, message_type=message_type
+ )
diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py
index 86d3488962..6029e0a4f6 100644
--- a/engine/apps/mattermost/models/user.py
+++ b/engine/apps/mattermost/models/user.py
@@ -2,7 +2,7 @@
class MattermostUser(models.Model):
- user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_connection")
+ user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_user_identity")
mattermost_user_id = models.CharField(max_length=100)
username = models.CharField(max_length=100)
nickname = models.CharField(max_length=100, null=True, blank=True, default=None)
diff --git a/engine/apps/mattermost/signals.py b/engine/apps/mattermost/signals.py
new file mode 100644
index 0000000000..f18b28bdc0
--- /dev/null
+++ b/engine/apps/mattermost/signals.py
@@ -0,0 +1,5 @@
+from apps.alerts.signals import alert_create_signal, alert_group_action_triggered_signal
+from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative
+
+alert_create_signal.connect(AlertGroupMattermostRepresentative.on_create_alert)
+alert_group_action_triggered_signal.connect(AlertGroupMattermostRepresentative.on_alert_group_action_triggered)
diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py
new file mode 100644
index 0000000000..274f0ed692
--- /dev/null
+++ b/engine/apps/mattermost/tasks.py
@@ -0,0 +1,75 @@
+import logging
+
+from celery.utils.log import get_task_logger
+from django.conf import settings
+from rest_framework import status
+
+from apps.alerts.models import Alert
+from apps.mattermost.alert_rendering import MattermostMessageRenderer
+from apps.mattermost.client import MattermostClient
+from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
+from apps.mattermost.models import MattermostChannel, MattermostMessage
+from common.custom_celery_tasks import shared_dedicated_queue_retry_task
+from common.utils import OkToRetry
+
+logger = get_task_logger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+@shared_dedicated_queue_retry_task(
+ bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
+)
+def on_create_alert_async(self, alert_pk):
+ """
+ It's async in order to prevent Mattermost downtime or formatting issues causing delay with SMS and other destinations.
+ """
+ try:
+ alert = Alert.objects.get(pk=alert_pk)
+ except Alert.DoesNotExist as e:
+ if on_create_alert_async.request.retries >= 10:
+ logger.error(f"Alert {alert_pk} was not found. Probably it was deleted. Stop retrying")
+ return
+ else:
+ raise e
+
+ alert_group = alert.group
+ mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group)
+ payload = MattermostMessageRenderer(alert_group).render_alert_group_message()
+
+ with OkToRetry(task=self, exc=(MattermostAPIException,), num_retries=3):
+ try:
+ client = MattermostClient()
+ mattermost_post = client.create_post(channel_id=mattermost_channel.channel_id, data=payload)
+ except MattermostAPITokenInvalid:
+ logger.error(f"Mattermost API token is invalid could not create post for alert {alert_pk}")
+ except MattermostAPIException as ex:
+ logger.error(f"Mattermost API error {ex}")
+ if ex.status not in [status.HTTP_401_UNAUTHORIZED]:
+ raise ex
+ else:
+ MattermostMessage.create_message(
+ alert_group=alert_group, post=mattermost_post, message_type=MattermostMessage.ALERT_GROUP_MESSAGE
+ )
+
+
+@shared_dedicated_queue_retry_task(
+ autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
+)
+def on_alert_group_action_triggered_async(log_record_id):
+ from apps.alerts.models import AlertGroupLogRecord
+ from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative
+
+ try:
+ log_record = AlertGroupLogRecord.objects.get(pk=log_record_id)
+ except AlertGroupLogRecord.DoesNotExist as e:
+ logger.warning(f"Mattermost representative: log record {log_record_id} never created or has been deleted")
+ raise e
+
+ alert_group_id = log_record.alert_group_id
+ logger.info(
+ f"Start mattermost on_alert_group_action_triggered for alert_group {alert_group_id}, log record {log_record_id}"
+ )
+ representative = AlertGroupMattermostRepresentative(log_record)
+ if representative.is_applicable():
+ handler = representative.get_handler()
+ handler(log_record.alert_group)
diff --git a/engine/apps/mattermost/urls.py b/engine/apps/mattermost/urls.py
index 0fba7b76d2..6743c7b1fc 100644
--- a/engine/apps/mattermost/urls.py
+++ b/engine/apps/mattermost/urls.py
@@ -1,8 +1,8 @@
from django.urls import include, path
-from common.api_helpers.optional_slash_router import OptionalSlashRouter
+from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
-from .views import MattermostChannelViewSet
+from .views import MattermostChannelViewSet, MattermostEventView
app_name = "mattermost"
router = OptionalSlashRouter()
@@ -10,4 +10,5 @@
urlpatterns = [
path("", include(router.urls)),
+ optional_slash_path("event", MattermostEventView.as_view(), name="incoming_mattermost_event"),
]
diff --git a/engine/apps/mattermost/utils.py b/engine/apps/mattermost/utils.py
new file mode 100644
index 0000000000..44f3a97daf
--- /dev/null
+++ b/engine/apps/mattermost/utils.py
@@ -0,0 +1,37 @@
+import typing
+import datetime
+import logging
+
+import jwt
+from django.conf import settings
+from django.utils import timezone
+
+from apps.mattermost.exceptions import MattermostEventTokenInvalid
+if typing.TYPE_CHECKING:
+ from apps.user_management.models import Organization
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+class MattermostEventAuthenticator:
+ @staticmethod
+ def create_token(organization: typing.Optional["Organization"]):
+ secret = settings.MATTERMOST_SIGNING_SECRET
+ expiration = timezone.now() + datetime.timedelta(days=30)
+ payload = {
+ "organization_id": organization.public_primary_key,
+ "exp": expiration,
+ }
+ token = jwt.encode(payload, secret, algorithm="HS256")
+ return token
+
+ @staticmethod
+ def verify(token: str):
+ secret = settings.MATTERMOST_SIGNING_SECRET
+ try:
+ payload = jwt.decode(token, secret, algorithms="HS256")
+ return payload
+ except jwt.InvalidTokenError as e:
+ logger.error(f"Error while verifying mattermost token {e}")
+ raise MattermostEventTokenInvalid(msg="Invalid token from mattermost server")
diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py
index feac1106cb..84fa263a1e 100644
--- a/engine/apps/mattermost/views.py
+++ b/engine/apps/mattermost/views.py
@@ -2,6 +2,7 @@
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
+from rest_framework.views import APIView
from apps.api.permissions import RBACPermission
from apps.auth_token.auth import PluginAuthentication
@@ -61,3 +62,29 @@ def perform_destroy(self, instance):
channel_id=instance.channel_id,
)
instance.delete()
+
+
+class MattermostEventView(APIView):
+ def get(self, request, format=None):
+ return Response("hello")
+
+ # Sample Request Payload
+ # {
+ # "user_id":"k8y8fccx57ygpq18oxp8pp3ntr",
+ # "user_name":"hbx80530",
+ # "channel_id":"gug81e7stfy8md747sewpeeqga",
+ # "channel_name":"camelcase",
+ # "team_id":"kjywdxcbjiyyupdgqst8bj8zrw",
+ # "team_domain":"local",
+ # "post_id":"cfsogqc61fbj3yssz78b1tarbw",
+ # "trigger_id":"cXJhd2Zwc2V3aW5nanBjY2I2YzdxdTc5NmE6azh5OGZjY3g1N3lncHExOG94cDhwcDNudHI6MTcyODgyMzQxODU4NzpNRVFDSUgvbURORjQrWFB1R1QzWHdTWGhDZG9rdEpNb3cydFNJL3l5QktLMkZrVjdBaUFaMjdybFB3c21EWUlyMHFIeVpKVnIyR1gwa2N6RzY5YkpuSDdrOEpuVXhnPT0=",
+ # "type":"",
+ # "data_source":"",
+ # "context":{
+ # "action":"acknowledge",
+ # "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb25faWQiOiJPMjlJWUQ3S0dRWURNIiwiZXhwIjoxNzMxNDE1Mzc0fQ.RbETrJS_lRDFDa9asGZbNlhMx13qkK0bc10-dj6x4-U"
+ # }
+ # }
+ def post(self, request):
+ # TODO: Implement the webhook
+ return Response(status=200)
diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py
index 552aaade45..25bfb33398 100644
--- a/engine/common/api_helpers/mixins.py
+++ b/engine/common/api_helpers/mixins.py
@@ -23,6 +23,7 @@
)
from apps.alerts.models import Alert, AlertGroup
from apps.base.messaging import get_messaging_backends
+from apps.mattermost.alert_rendering import AlertMattermostTemplater
from common.api_helpers.exceptions import BadRequest
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
@@ -238,8 +239,9 @@ def filter_lookups(child):
PHONE_CALL = "phone_call"
SMS = "sms"
TELEGRAM = "telegram"
+MATTERMOST = "mattermost"
# templates with its own field in db, this concept replaced by messaging_backend_templates field
-NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM]
+NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM, MATTERMOST]
TITLE = "title"
MESSAGE = "message"
@@ -258,6 +260,7 @@ def filter_lookups(child):
PHONE_CALL: AlertPhoneCallTemplater,
SMS: AlertSmsTemplater,
TELEGRAM: AlertTelegramTemplater,
+ MATTERMOST: AlertMattermostTemplater,
}
# add additionally supported messaging backends
diff --git a/engine/settings/base.py b/engine/settings/base.py
index aa7a928175..cc6389f49f 100644
--- a/engine/settings/base.py
+++ b/engine/settings/base.py
@@ -727,6 +727,7 @@ class BrokerTypes:
MATTERMOST_HOST = os.environ.get("MATTERMOST_HOST")
MATTERMOST_BOT_TOKEN = os.environ.get("MATTERMOST_BOT_TOKEN")
MATTERMOST_LOGIN_RETURN_REDIRECT_HOST = os.environ.get("MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", None)
+MATTERMOST_SIGNING_SECRET = os.environ.get("MATTERMOST_SIGNING_SECRET", None)
SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID
SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET
diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py
index 29309a7196..bca554abab 100644
--- a/engine/settings/celery_task_routes.py
+++ b/engine/settings/celery_task_routes.py
@@ -189,4 +189,7 @@
"apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"},
"apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"},
"apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"},
+ # MATTERMOST
+ "apps.mattermost.tasks.on_create_alert_async": {"queue": "mattermost"},
+ "apps.mattermost.tasks.on_alert_group_action_triggered_async": {"queue": "mattermost"},
}
diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts
index b8c5efb0c9..1b0d6c4b07 100644
--- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts
+++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts
@@ -1,15 +1,21 @@
+import { merge } from 'lodash-es'
+
import { AppFeature } from 'state/features';
import { TemplateForEdit, commonTemplateForEdit } from './CommonAlertTemplatesForm.config';
export const getTemplatesForEdit = (features: Record) => {
+ const templatesForEdit = {...commonTemplateForEdit}
if (features?.[AppFeature.MsTeams]) {
- return { ...commonTemplateForEdit, ...additionalTemplateForEdit };
+ merge(templatesForEdit, msteamsTemplateForEdit)
+ }
+ if (features?.[AppFeature.Mattermost]) {
+ merge(templatesForEdit, mattermostTemplateForEdit)
}
- return commonTemplateForEdit;
+ return templatesForEdit;
};
-const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = {
+const msteamsTemplateForEdit: { [id: string]: TemplateForEdit } = {
msteams_title_template: {
name: 'msteams_title_template',
displayName: 'MS Teams title',
@@ -42,4 +48,37 @@ const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = {
},
};
+const mattermostTemplateForEdit: { [id: string]: TemplateForEdit } = {
+ mattermost_title_template: {
+ name: 'mattermost_title_template',
+ displayName: 'Mattermost title',
+ description: '',
+ additionalData: {
+ chatOpsName: 'mattermost',
+ chatOpsDisplayName: 'Mattermost',
+ },
+ type: 'plain',
+ },
+ mattermost_message_template: {
+ name: 'mattermost_message_template',
+ displayName: 'Mattermost message',
+ description: '',
+ additionalData: {
+ chatOpsName: 'mattermost',
+ chatOpsDisplayName: 'Mattermost',
+ },
+ type: 'plain',
+ },
+ mattermost_image_url_template: {
+ name: 'mattermost_image_url_template',
+ displayName: 'Mattermost image url',
+ description: '',
+ additionalData: {
+ chatOpsName: 'mattermost',
+ chatOpsDisplayName: 'Mattermost',
+ },
+ type: 'plain',
+ },
+};
+
export const FORM_NAME = 'AlertTemplates';
diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts
index 59b79a5469..616d2ad4e0 100644
--- a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts
+++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts
@@ -1,3 +1,5 @@
+import { clone } from 'lodash-es'
+
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config';
import { AppFeature } from 'state/features';
@@ -24,11 +26,35 @@ const additionalTemplatesToRender: TemplateBlock[] = [
},
],
},
+ {
+ name: 'Mattermost',
+ contents: [
+ {
+ name: 'mattermost_title_template',
+ label: 'Title',
+ height: MONACO_INPUT_HEIGHT_SMALL,
+ },
+ {
+ name: 'mattermost_message_template',
+ label: 'Message',
+ height: MONACO_INPUT_HEIGHT_TALL,
+ },
+ {
+ name: 'mattermost_image_url_template',
+ label: 'Image',
+ height: MONACO_INPUT_HEIGHT_SMALL,
+ },
+ ],
+ }
];
export const getTemplatesToRender = (features?: Record) => {
+ const templatesToRender = clone(commonTemplatesToRender)
if (features?.[AppFeature.MsTeams]) {
- return commonTemplatesToRender.concat(additionalTemplatesToRender);
+ templatesToRender.push(additionalTemplatesToRender[0]);
+ }
+ if (features?.[AppFeature.Mattermost]) {
+ templatesToRender.push(additionalTemplatesToRender[1])
}
- return commonTemplatesToRender;
+ return templatesToRender;
};