Refactor Webhooks to WebhookFactory

To save having a massive class lets go modular
This commit is contained in:
Zedifus 2023-09-28 19:17:47 +01:00
parent 1ea0d692d0
commit 409d7618dd
8 changed files with 274 additions and 165 deletions

View File

@ -25,7 +25,7 @@ from app.classes.controllers.roles_controller import RolesController
from app.classes.shared.helpers import Helpers
from app.classes.shared.main_models import DatabaseShortcuts
from app.classes.web.base_handler import BaseHandler
from app.classes.web.webhook_handler import WebhookHandler
from app.classes.web.webhooks.webhook_factory import WebhookFactory
logger = logging.getLogger(__name__)
@ -345,7 +345,9 @@ class PanelHandler(BaseHandler):
) as credits_default_local:
try:
remote = requests.get(
"https://craftycontrol.com/credits-v2", allow_redirects=True
"https://craftycontrol.com/credits-v2",
allow_redirects=True,
timeout=10,
)
credits_dict: dict = remote.json()
if not credits_dict["staff"]:
@ -1070,8 +1072,8 @@ class PanelHandler(BaseHandler):
page_data["webhook"]["body"] = ""
page_data["webhook"]["enabled"] = True
page_data["providers"] = WebhookHandler.get_providers()
page_data["triggers"] = WebhookHandler.get_monitored_actions()
page_data["providers"] = WebhookFactory.get_supported_providers()
page_data["triggers"] = WebhookFactory.get_monitored_events()
if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]:
if not superuser:
@ -1121,8 +1123,8 @@ class PanelHandler(BaseHandler):
page_data["webhook"]["trigger"]
).split(",")
page_data["providers"] = WebhookHandler.get_providers()
page_data["triggers"] = WebhookHandler.get_monitored_actions()
page_data["providers"] = WebhookFactory.get_supported_providers
page_data["triggers"] = WebhookFactory.get_monitored_events
if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]:
if not superuser:

View File

@ -6,14 +6,17 @@ import logging
from jsonschema import ValidationError, validate
from app.classes.models.server_permissions import EnumPermissionsServer
from app.classes.web.base_api_handler import BaseApiHandler
from app.classes.web.webhook_handler import WebhookHandler
from app.classes.web.webhooks.webhook_factory import WebhookFactory
logger = logging.getLogger(__name__)
new_webhook_schema = {
"type": "object",
"properties": {
"webhook_type": {"type": "string", "enum": WebhookHandler.get_providers()},
"webhook_type": {
"type": "string",
"enum": WebhookFactory.get_supported_providers(),
},
"name": {"type": "string"},
"url": {"type": "string"},
"bot_name": {"type": "string"},

View File

@ -3,11 +3,9 @@
import json
import logging
from croniter import croniter
from jsonschema import ValidationError, validate
from app.classes.models.server_permissions import EnumPermissionsServer
from app.classes.web.webhook_handler import WebhookHandler
from app.classes.web.webhooks.webhook_factory import WebhookFactory
from app.classes.web.base_api_handler import BaseApiHandler
@ -16,7 +14,10 @@ logger = logging.getLogger(__name__)
webhook_patch_schema = {
"type": "object",
"properties": {
"webhook_type": {"type": "string", "enum": WebhookHandler.get_providers()},
"webhook_type": {
"type": "string",
"enum": WebhookFactory.get_supported_providers(),
},
"name": {"type": "string"},
"url": {"type": "string"},
"bot_name": {"type": "string"},
@ -168,8 +169,13 @@ class ApiServersServerWebhooksManagementIndexHandler(BaseApiHandler):
return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
webhook = self.controller.management.get_webhook_by_id(webhook_id)
try:
WebhookHandler.send_discord_webhook(
webhook["bot_name"], webhook["url"], webhook["body"], 880808
webhook_provider = WebhookFactory.create_provider(webhook["webhook_type"])
webhook_provider.send(
server_name=server_id, # TODO get actual server name
title="Tickle Test Webhook",
url=webhook["url"],
message=webhook["body"],
color=4915409, # Prestigious purple!
)
except Exception as e:
self.finish_json(500, {"status": "error", "error": str(e)})

View File

@ -1,151 +0,0 @@
import logging
from datetime import datetime
import requests
from app.classes.shared.helpers import Helpers
logger = logging.getLogger(__name__)
helper = Helpers()
class WebhookHandler:
WEBHOOK_USERNAME = "Crafty Webhooks"
WEBHOOK_PFP_URL = (
"https://gitlab.com/crafty-controller/crafty-4/-"
+ "/raw/master/app/frontend/static/assets/images/"
+ "Crafty_4-0.png"
)
CRAFTY_VERSION = helper.get_version_string()
@staticmethod
def get_providers():
return [
"Discord",
"Mattermost",
"Signal",
"Slack",
"SMTP",
"Splunk",
"Teams",
"Telegram",
"Custom",
]
@staticmethod
def get_monitored_actions():
return ["server_start", "server_stop", "server_crash", "server_backup"]
@staticmethod
def send_discord_webhook(server_name, title, url, message, color):
"""
Sends a message to a Discord channel via a webhook.
This method prepares a payload for the Discord webhook API using
the message content, Crafty Controller version, and the current UTC datetime.
It dispatches this payload to the specified webhook URL.
Parameters:
- server_name (str): Name of the server, used as 'author' in the Discord embed.
- title (str): Title of the message in the Discord embed.
- url (str): URL of the Discord webhook.
- message (str): Main content of the message in the Discord embed.
- color (int): Color code for the side stripe in the Discord message.
Returns:
str: "Dispatch successful!" if the message is sent successfully, otherwise an
exception is raised.
Raises:
Exception: If there's an error in dispatching the webhook.
Note:
- Docs: https://discord.com/developers/docs/resources/webhook#execute-webhook
- Webhook request times out after 10 seconds to prevent indefinite hanging.
"""
# Get the current UTC datetime
current_datetime = datetime.utcnow()
# Format the datetime to discord's required UTC string format
# "YYYY-MM-DDTHH:MM:SS.MSSZ"
formatted_datetime = (
current_datetime.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
)
# Prepare webhook payload
headers = {"Content-type": "application/json"}
payload = {
"username": WebhookHandler.WEBHOOK_USERNAME,
"avatar_url": WebhookHandler.WEBHOOK_PFP_URL,
"embeds": [
{
"title": title,
"description": message,
"color": color,
"author": {"name": server_name},
"footer": {
"text": f"Crafty Controller v.{WebhookHandler.CRAFTY_VERSION}"
},
"timestamp": formatted_datetime,
}
],
}
# Dispatch webhook
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
return "Dispatch successful"
except requests.RequestException as error:
logger.error(error)
raise RuntimeError(f"Failed to dispatch notification: {error}") from error
@staticmethod
def send_mattermost_webhook(server_name, title, url, message):
"""
Sends a message to a Mattermost channel via an incoming webhook.
Parameters:
- server_name (str): Name of the server, used as 'author' in the Discord embed.
- title (str): Title of the message in the Discord embed.
- url (str): URL of the Discord webhook.
- message (str): Main content of the message in the Discord embed.
Returns:
str: "Dispatch successful!" if the message is sent successfully, otherwise an
exception is raised.
Raises:
Exception: If there's an error in dispatching the webhook.
Note:
- Docs: https://developers.mattermost.com/integrate/webhooks/incoming
- Webhook request times out after 10 seconds to prevent indefinite hanging.
"""
# Format the text for Mattermost
formatted_text = f"#### {title} \n *Server: {server_name}* \n\n {message}"
# Prepare webhook payload
headers = {"Content-Type": "application/json"}
payload = {
"text": formatted_text,
"username": WebhookHandler.WEBHOOK_USERNAME,
"icon_url": WebhookHandler.WEBHOOK_PFP_URL,
"props": {
"card": (
f"[Crafty Controller "
f"v.{WebhookHandler.CRAFTY_VERSION}](https://craftycontrol.com)"
)
},
}
# Dispatch webhook
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
return "Dispatch successful"
except requests.RequestException as error:
logger.error(error)
raise RuntimeError(
f"Failed to dispatch notification to Mattermost: {error}"
) from error

View File

@ -0,0 +1,39 @@
from abc import ABC, abstractmethod
import logging
import requests
from app.classes.shared.helpers import Helpers
logger = logging.getLogger(__name__)
helper = Helpers()
class WebhookProvider(ABC):
"""
Base class for all webhook providers.
Provides a common interface for all webhook provider implementations,
ensuring that each provider will have a send method.
"""
WEBHOOK_USERNAME = "Crafty Webhooks"
WEBHOOK_PFP_URL = (
"https://gitlab.com/crafty-controller/crafty-4/-"
+ "/raw/master/app/frontend/static/assets/images/"
+ "Crafty_4-0.png"
)
CRAFTY_VERSION = helper.get_version_string()
def _send_request(self, url, payload, headers=None):
"""Send a POST request to the given URL with the provided payload."""
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
return "Dispatch successful"
except requests.RequestException as error:
logger.error(error)
raise RuntimeError(f"Failed to dispatch notification: {error}") from error
@abstractmethod
def send(self, server_name, title, url, message, **kwargs):
"""Abstract method that derived classes will implement for sending webhooks."""

View File

@ -0,0 +1,71 @@
from datetime import datetime
from app.classes.web.webhooks.base_webhook import WebhookProvider
class DiscordWebhook(WebhookProvider):
def _construct_discord_payload(self, server_name, title, message, color):
"""
Constructs the payload required for sending a Discord webhook notification.
This method prepares a payload for the Discord webhook API using the provided
message content, the Crafty Controller version, and the current UTC datetime.
Parameters:
server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message.
message (str): The main content of the notification message.
color (int): The color code for the side stripe in the Discord embed message.
Returns:
tuple: A tuple containing the constructed payload (dict) incl headers (dict).
"""
current_datetime = datetime.utcnow()
formatted_datetime = (
current_datetime.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
)
headers = {"Content-type": "application/json"}
payload = {
"username": self.WEBHOOK_USERNAME,
"avatar_url": self.WEBHOOK_PFP_URL,
"embeds": [
{
"title": title,
"description": message,
"color": color,
"author": {"name": server_name},
"footer": {"text": f"Crafty Controller v.{self.CRAFTY_VERSION}"},
"timestamp": formatted_datetime,
}
],
}
return payload, headers
def send(self, server_name, title, url, message, **kwargs):
"""
Sends a Discord webhook notification using the given details.
The method constructs and dispatches a payload suitable for
Discords's webhook system.
Parameters:
server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message.
url (str): The webhook URL to send the notification to.
message (str): The main content or body of the notification message.
color (int, optional): The color code for the embed's side stripe.
Defaults to a pretty blue if not provided.
Returns:
str: "Dispatch successful!" if the message is sent successfully, otherwise an
exception is raised.
Raises:
Exception: If there's an error in dispatching the webhook.
"""
color = kwargs.get("color", 23761) # Default to a color if not provided.
payload, headers = self._construct_discord_payload(
server_name, title, message, color
)
return self._send_request(url, payload, headers)

View File

@ -0,0 +1,60 @@
from app.classes.web.webhooks.base_webhook import WebhookProvider
class MattermostWebhook(WebhookProvider):
def _construct_mattermost_payload(self, server_name, title, message):
"""
Constructs the payload required for sending a Mattermost webhook notification.
The method formats the given information into a Markdown-styled message for MM,
including an information card containing the Crafty version.
Parameters:
server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message.
message (str): The main content of the notification message.
Returns:
tuple: A tuple containing the constructed payload (dict) incl headers (dict).
"""
formatted_text = f"#### {title} \n *Server: {server_name}* \n\n {message}"
headers = {"Content-Type": "application/json"}
payload = {
"text": formatted_text,
"username": self.WEBHOOK_USERNAME,
"icon_url": self.WEBHOOK_PFP_URL,
"props": {
"card": (
f"[Crafty Controller "
f"v.{self.CRAFTY_VERSION}](https://craftycontrol.com)"
)
},
}
return payload, headers
def send(self, server_name, title, url, message, **kwargs):
"""
Sends a Mattermost webhook notification using the given details.
The method constructs and dispatches a payload suitable for
Mattermost's webhook system.
Parameters:
server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message.
url (str): The webhook URL to send the notification to.
message (str): The main content or body of the notification message.
Returns:
str: "Dispatch successful!" if the message is sent successfully, otherwise an
exception is raised.
Raises:
Exception: If there's an error in dispatching the webhook.
"""
payload, headers = self._construct_mattermost_payload(
server_name, title, message
)
return self._send_request(url, payload, headers)

View File

@ -0,0 +1,79 @@
from app.classes.web.webhooks.discord_webhook import DiscordWebhook
from app.classes.web.webhooks.mattermost_webhook import MattermostWebhook
class WebhookFactory:
"""
A factory class responsible for the creation and management of webhook providers.
This class provides methods to instantiate specific webhook providers based on
their name and to retrieve a list of supported providers. It uses a registry pattern
to manage the available providers.
Attributes:
- _registry (dict): A dictionary mapping provider names to their classes.
"""
_registry = {
"Discord": DiscordWebhook,
"Mattermost": MattermostWebhook,
# "Signal",
# "Slack",
# "SMTP",
# "Splunk",
# "Teams",
# "Telegram",
# "Custom",
}
@classmethod
def create_provider(cls, provider_name, *args, **kwargs):
"""
Creates and returns an instance of the specified webhook provider.
This method looks up the provider in the registry, then instantiates it w/ the
provided arguments. If the provider is not recognized, a ValueError is raised.
Arguments:
- provider_name (str): The name of the desired webhook provider.
Additional arguments supported that we may use for if a provider
requires initialization:
- *args: Positional arguments to pass to the provider's constructor.
- **kwargs: Keyword arguments to pass to the provider's constructor.
Returns:
WebhookProvider: An instance of the desired webhook provider.
Raises:
ValueError: If the specified provider name is not recognized.
"""
if provider_name not in cls._registry:
raise ValueError(f"Provider {provider_name} is not supported.")
return cls._registry[provider_name](*args, **kwargs)
@classmethod
def get_supported_providers(cls):
"""
Retrieves the names of all supported webhook providers.
This method returns a list containing the names of all providers
currently registered in the factory's registry.
Returns:
List[str]: A list of supported provider names.
"""
return list(cls._registry.keys())
@staticmethod
def get_monitored_events():
"""
Retrieves the list of supported events for monitoring.
This method provides a list of common server events that the webhook system can
monitor and notify about.
Returns:
List[str]: A list of supported monitored actions.
"""
return ["server_start", "server_stop", "server_crash", "server_backup"]