diff --git a/Makefile b/Makefile index 77215b0..860e194 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,7 @@ test: $(TEST_CMD) -s -v test_last_failed: - $(TEST_CMD) -s -v --last-failed + $(TEST_CMD) -s -vv --last-failed test_: test_last_failed diff --git a/deploy/templates/app/intbot.env.example b/deploy/templates/app/intbot.env.example index 2fbdb49..ce35f62 100644 --- a/deploy/templates/app/intbot.env.example +++ b/deploy/templates/app/intbot.env.example @@ -34,3 +34,8 @@ GITHUB_WEBHOOK_SECRET_TOKEN="github-webhook-secret-token" GITHUB_BOARD_PROJECT_ID="GITHUB_BOARD_PROJECT_ID" GITHUB_EP2025_PROJECT_ID="GITHUB_EP2025_PROJECT_ID" GITHUB_EM_PROJECT_ID="GITHUB_EM_PROJECT_ID" + +# Zammad +ZAMMAD_WEBHOOK_SECRET_TOKEN="zammad-shared-secret-goes-here" +ZAMMAD_GROUP_BILLING="zammad-billing-group-name-goes-here" +ZAMMAD_GROUP_HELPDESK="zammad-helpdesk-group-name-goes-here" diff --git a/intbot/core/bot/channel_router.py b/intbot/core/bot/channel_router.py index 66996d5..3f740ce 100644 --- a/intbot/core/bot/channel_router.py +++ b/intbot/core/bot/channel_router.py @@ -9,6 +9,7 @@ GithubRepositories, parse_github_webhook, ) +from core.integrations.zammad import ZammadConfig from core.models import Webhook from django.conf import settings @@ -23,6 +24,7 @@ class DiscordChannel: class Channels: + # Github test_channel = DiscordChannel( channel_id=settings.DISCORD_TEST_CHANNEL_ID, channel_name=settings.DISCORD_TEST_CHANNEL_NAME, @@ -49,11 +51,24 @@ class Channels: channel_name=settings.DISCORD_BOT_CHANNEL_NAME, ) + # Zammad + billing_channel = DiscordChannel( + channel_id=settings.DISCORD_BILLING_CHANNEL_ID, + channel_name=settings.DISCORD_BILLING_CHANNEL_NAME, + ) + helpdesk_channel = DiscordChannel( + channel_id=settings.DISCORD_HELPDESK_CHANNEL_ID, + channel_name=settings.DISCORD_HELPDESK_CHANNEL_NAME, + ) + def discord_channel_router(wh: Webhook) -> DiscordChannel: if wh.source == "github": return github_router(wh) + elif wh.source == "zammad": + return zammad_router(wh) + elif wh.source == "internal": return internal_router(wh) @@ -91,6 +106,19 @@ def github_router(wh: Webhook) -> DiscordChannel: return dont_send_it +def zammad_router(wh: Webhook) -> DiscordChannel: + groups = { + ZammadConfig.helpdesk_group: Channels.helpdesk_channel, + ZammadConfig.billing_group: Channels.billing_channel, + } + + if channel := groups.get(wh.extra["group"]): + return channel + + # If it doesn't match any of the groups, just skip it + return dont_send_it + + def internal_router(wh: Webhook) -> DiscordChannel: # For now just send all the internal messages to a test channel return Channels.test_channel diff --git a/intbot/core/endpoints/webhooks.py b/intbot/core/endpoints/webhooks.py index d7716d0..18cc1c4 100644 --- a/intbot/core/endpoints/webhooks.py +++ b/intbot/core/endpoints/webhooks.py @@ -117,7 +117,7 @@ def zammad_webhook_endpoint(request): process_webhook.enqueue(str(wh.uuid)) return JsonResponse({"status": "created", "guid": wh.uuid}) - return HttpResponseNotAllowed("Only POST") + return HttpResponseNotAllowed(permitted_methods=["POST"]) def verify_zammad_signature(request) -> str: diff --git a/intbot/core/integrations/zammad.py b/intbot/core/integrations/zammad.py new file mode 100644 index 0000000..074f263 --- /dev/null +++ b/intbot/core/integrations/zammad.py @@ -0,0 +1,161 @@ +from datetime import datetime +from django.conf import settings + +from core.models import Webhook +from pydantic import BaseModel + + +class ZammadConfig: + url = settings.ZAMMAD_URL # servicedesk.europython.eu + billing_group = settings.ZAMMAD_GROUP_BILLING + helpdesk_group = settings.ZAMMAD_GROUP_HELPDESK + +class ZammadGroup(BaseModel): + id: int + name: str + + +class ZammadUser(BaseModel): + firstname: str + lastname: str + + +class ZammadTicket(BaseModel): + id: int + group: ZammadGroup + title: str + owner: ZammadUser + state: str + number: str + customer: ZammadUser + created_at: datetime + updated_at: datetime + updated_by: ZammadUser + article_ids: list[int] + + +class ZammadArticle(BaseModel): + sender: str + internal: bool + ticket_id: int + created_at: datetime + created_by: ZammadUser + subject: str + + +class ZammadWebhook(BaseModel): + ticket: ZammadTicket + article: ZammadArticle | None + + +JsonType = dict[str, str | int | float | list | dict] + + +class ZammadParser: + + class Actions: + new_ticket_created = "new_ticket_created" + new_message_in_thread = "new_message_in_thread" + replied_in_thread = "replied_in_thread" + new_internal_note = "new_internal_note" + updated_ticket = "updated_ticket" + + def __init__(self, content: JsonType): + self.content = content + # Ticket is always there, article is optional + # Example: change of status of the Ticket doesn't contain article + self.ticket = ZammadTicket.model_validate(self.content["ticket"]) + self.article = ( + ZammadArticle.model_validate(self.content["article"]) + if self.content["article"] + else None + ) + + @property + def action(self): + """ + Zammad doesn't give us an action inside the webhook, so we can either + set custom triggers and URLs for every action, or we can try to infer + the action from the content of the webhook. For simplicity of the + overall setup, we are implementing the latter here. + + "New Ticket created"? -- has article, and len(article_ids) == 1 + -- state change will not have article associated with it. + "New message in the thread" -- article, sender==Customer + "We sent a new reply in the thread" -- article, sender==Agent + "New internal note in the thread" -- article, internal==true + "Updated the ticket ...", -- updated_by.firstname + """ + # Implementing this as cascading if statements here is part of the + # assumptions. + # For example the "sender == Customer" is going to be True also for their + # first message that originally creates the ticket. However first time + # we get a message, we will return "New ticket" and second time "New + # message in the thread". + if self.article: + if len(self.ticket.article_ids) == 1: + # This means we have an article, and it's a first one, therefore a + # ticket is new. + return self.Actions.new_ticket_created + + elif self.article.internal is True: + return self.Actions.new_internal_note + + elif self.article.sender == "Customer": + return self.Actions.new_message_in_thread + + elif self.article.sender == "Agent": + return self.Actions.replied_in_thread + + elif not self.article: + return self.Actions.updated_ticket + + raise ValueError("Unsupported scenario") + + @property + def updated_by(self): + return self.ticket.updated_by.firstname + + @property + def group(self): + return self.ticket.group.name + + @property + def url(self): + return f"https://{ZammadConfig.url}/#ticket/zoom/{self.ticket.id}" + + def to_discord_message(self): + message = "{group}: {sender} {action} {details}".format + + # Action + actions = { + self.Actions.new_ticket_created: "created new ticket", + self.Actions.new_message_in_thread: "sent a new message", + self.Actions.replied_in_thread: "replied to a ticket", + self.Actions.new_internal_note: "created internal note", + self.Actions.updated_ticket: "updated ticket", + } + + action = actions[self.action] + + return message(group=self.group, sender=self.updated_by, action=action, details=self.url) + + def meta(self): + return { + "group": self.group, + "sender": self.updated_by, + "action": self.action, + "message": self.to_discord_message(), + } + + +def prep_zammad_webhook(wh: Webhook): + """Parse and store some information for later""" + zp = ZammadParser(wh.content) + + wh.event = zp.action + wh.extra = zp.meta() + + wh.save() + + return wh diff --git a/intbot/core/tasks.py b/intbot/core/tasks.py index 8a96e79..70d27f6 100644 --- a/intbot/core/tasks.py +++ b/intbot/core/tasks.py @@ -1,6 +1,7 @@ import logging from core.integrations.github import parse_github_webhook, prep_github_webhook +from core.integrations.zammad import prep_zammad_webhook from core.bot.channel_router import discord_channel_router, dont_send_it from core.models import DiscordMessage, Webhook from django.utils import timezone @@ -58,6 +59,8 @@ def process_github_webhook(wh: Webhook): channel = discord_channel_router(wh) if channel == dont_send_it: + # Mark as processed, to avoid re-processing in the future if we + # shouldn't send a message. wh.processed_at = timezone.now() wh.save() return @@ -66,7 +69,7 @@ def process_github_webhook(wh: Webhook): channel_id=channel.channel_id, channel_name=channel.channel_name, content=f"GitHub: {parsed.as_discord_message()}", - # Mark as unsend - to be sent with the next batch + # Mark as unsent - to be sent with the next batch sent_at=None, ) wh.processed_at = timezone.now() @@ -74,6 +77,29 @@ def process_github_webhook(wh: Webhook): def process_zammad_webhook(wh: Webhook): - # NOTE(artcz) Do nothing for now. Just a placeholder. - # Processing will come in the next PR. - return + if wh.source != "zammad": + raise ValueError("Incorrect wh.source = {wh.source}") + + # Unlike in github, the zammad webhook is richer and + # contains much more information, so no extra fetch is needed. + # However, we can extract information and store it in the meta field, that + # way we can reuse it later more easily. + wh = prep_zammad_webhook(wh) + channel = discord_channel_router(wh) + + if channel == dont_send_it: + # Mark as processed, to avoid re-processing in the future if we + # shouldn't send a message. + wh.processed_at = timezone.now() + wh.save() + return + + DiscordMessage.objects.create( + channel_id=channel.channel_id, + channel_name=channel.channel_name, + content=f"Zammad: {wh.meta['message']}", + # Mark as unsent - to be sent with the next batch + sent_at=None, + ) + wh.processed_at = timezone.now() + wh.save() diff --git a/intbot/intbot/settings.py b/intbot/intbot/settings.py index bc596fa..3a102e6 100644 --- a/intbot/intbot/settings.py +++ b/intbot/intbot/settings.py @@ -159,6 +159,11 @@ def get(name) -> str: DISCORD_BOT_CHANNEL_ID = get("DISCORD_BOT_CHANNEL_ID") DISCORD_BOT_CHANNEL_NAME = get("DISCORD_BOT_CHANNEL_NAME") +DISCORD_HELPDESK_CHANNEL_ID = get("DISCORD_HELPDESK_CHANNEL_ID") +DISCORD_HELPDESK_CHANNEL_NAME = get("DISCORD_HELPDESK_CHANNEL_NAME") +DISCORD_BILLING_CHANNEL_ID = get("DISCORD_BILLING_CHANNEL_ID") +DISCORD_BILLING_CHANNEL_NAME = get("DISCORD_BILLING_CHANNEL_NAME") + # Github GITHUB_API_TOKEN = get("GITHUB_API_TOKEN") GITHUB_WEBHOOK_SECRET_TOKEN = get("GITHUB_WEBHOOK_SECRET_TOKEN") @@ -170,6 +175,12 @@ def get(name) -> str: # Zammad ZAMMAD_WEBHOOK_SECRET_TOKEN = get("ZAMMAD_WEBHOOK_SECRET_TOKEN") +ZAMMAD_URL = "servicedesk.europython.eu" +ZAMMAD_GROUP_BILLING = get("ZAMMAD_GROUP_BILLING") +ZAMMAD_GROUP_HELPDESK = get("ZAMMAD_GROUP_HELPDESK") + + + if DJANGO_ENV == "dev": DEBUG = True ALLOWED_HOSTS = ["127.0.0.1", "localhost"] @@ -246,6 +257,16 @@ def get(name) -> str: DISCORD_EM_CHANNEL_NAME = "em_channel" DISCORD_EM_CHANNEL_ID = "123123" + DISCORD_HELPDESK_CHANNEL_ID = "1237777" + DISCORD_HELPDESK_CHANNEL_NAME = "helpdesk_channel" + DISCORD_BILLING_CHANNEL_ID = "123999" + DISCORD_BILLING_CHANNEL_NAME = "billing_channel" + + ZAMMAD_GROUP_HELPDESK = "TestZammad Helpdesk" + ZAMMAD_GROUP_BILLING = "TestZammad Billing" + + + elif DJANGO_ENV == "local_container": DEBUG = False diff --git a/intbot/tests/test_bot/test_channel_router.py b/intbot/tests/test_bot/test_channel_router.py index 536a6e6..08aae40 100644 --- a/intbot/tests/test_bot/test_channel_router.py +++ b/intbot/tests/test_bot/test_channel_router.py @@ -2,11 +2,16 @@ Integrated tests for the Discord Channel Router """ -from core.bot.channel_router import Channels, discord_channel_router +from core.bot.channel_router import Channels, discord_channel_router, dont_send_it from core.models import Webhook class TestDiscordChannelRouter: + def test_it_doesnt_send_unkown_messages(self): + wh = Webhook(source="unkown") + channel = discord_channel_router(wh) + assert channel == dont_send_it + def test_it_routes_board_project_to_board_channel(self): wh = Webhook( source="github", @@ -66,3 +71,49 @@ def test_it_routes_EM_project_to_EM_channel(self): assert channel == Channels.em_channel assert channel.channel_name == "em_channel" assert channel.channel_id == "123123" + + def test_it_routes_zammad_billing_queue_to_billing_channel(self): + wh = Webhook( + source="zammad", + meta={}, + content={}, + extra={ + "group": "TestZammad Billing", + }, + ) + + channel = discord_channel_router(wh) + + assert channel == Channels.billing_channel + assert channel.channel_name == "billing_channel" + assert channel.channel_id == "123999" + + def test_it_routes_zammad_helpdesk_queue_to_helpdesk_channel(self): + wh = Webhook( + source="zammad", + meta={}, + content={}, + extra={ + "group": "TestZammad Helpdesk", + }, + ) + + channel = discord_channel_router(wh) + + assert channel == Channels.helpdesk_channel + assert channel.channel_name == "helpdesk_channel" + assert channel.channel_id == "1237777" + + def test_it_skips_unkown_zammad_groups(self): + wh = Webhook( + source="zammad", + meta={}, + content={}, + extra={ + "group": "Unkown Random Group", + }, + ) + + channel = discord_channel_router(wh) + + assert channel == dont_send_it diff --git a/intbot/tests/test_integrations/test_zammad.py b/intbot/tests/test_integrations/test_zammad.py new file mode 100644 index 0000000..07010ee --- /dev/null +++ b/intbot/tests/test_integrations/test_zammad.py @@ -0,0 +1,265 @@ + +import pytest +from core.integrations.zammad import ZammadParser, prep_zammad_webhook +from core.models import Webhook +from django.utils import timezone + + +def test_zammad_parser_with_new_ticket(): + js = { + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Cookie", "lastname": "Monster"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "open", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "article_ids": [1], + }, + "article": { + "sender": "Customer", + "internal": False, + "ticket_id": 123, + "created_at": timezone.now(), + "created_by": {"firstname": "Cookie", "lastname": "Monster"}, + "subject": "New Cookies please", + }, + } + + zp = ZammadParser(js) + + assert zp.meta() == { + "group": "TestHelpdesk", + "sender": "Cookie", + "action": "new_ticket_created", + "message": ( + "TestHelpdesk: Cookie created new ticket " + "https://servicedesk.europython.eu/#ticket/zoom/123" + ), + } + + +def test_zammad_parser_with_new_message(): + js = { + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Cookie", "lastname": "Monster"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "open", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "article_ids": [1, 2], + }, + "article": { + "sender": "Customer", + "internal": False, + "ticket_id": 123, + "created_at": timezone.now(), + "created_by": {"firstname": "Cookie", "lastname": "Monster"}, + "subject": "New Cookies please", + }, + } + + zp = ZammadParser(js) + + assert zp.meta() == { + "group": "TestHelpdesk", + "sender": "Cookie", + "action": "new_message_in_thread", + "message": ( + "TestHelpdesk: Cookie sent a new message " + "https://servicedesk.europython.eu/#ticket/zoom/123" + ), + } + + +def test_zammad_replied_to_a_ticket(): + js = { + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "open", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "article_ids": [1, 2], + }, + "article": { + "sender": "Agent", + "internal": False, + "ticket_id": 123, + "created_at": timezone.now(), + "created_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "subject": "New Cookies please", + }, + } + + zp = ZammadParser(js) + + assert zp.meta() == { + "group": "TestHelpdesk", + "sender": "Kermit", + "action": "replied_in_thread", + "message": ( + "TestHelpdesk: Kermit replied to a ticket " + "https://servicedesk.europython.eu/#ticket/zoom/123" + ), + } + + +def test_zammad_create_internal_note(): + js = { + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "open", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "article_ids": [1, 2], + }, + "article": { + "sender": "Agent", + "internal": True, + "ticket_id": 123, + "created_at": timezone.now(), + "created_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "subject": "New Cookies please", + }, + } + + zp = ZammadParser(js) + + assert zp.meta() == { + "group": "TestHelpdesk", + "sender": "Kermit", + "action": "new_internal_note", + "message": ( + "TestHelpdesk: Kermit created internal note " + "https://servicedesk.europython.eu/#ticket/zoom/123" + ), + } + + +def test_zammad_updated_ticket(): + js = { + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "closed", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "article_ids": [1, 2], + }, + # No article, just change of status + "article": {}, + } + + zp = ZammadParser(js) + + assert zp.meta() == { + "group": "TestHelpdesk", + "sender": "Kermit", + "action": "updated_ticket", + "message": ( + "TestHelpdesk: Kermit updated ticket " + "https://servicedesk.europython.eu/#ticket/zoom/123" + ), + } + + +def test_zammad_unsupported_scenario(): + """Just for completeness and coverage""" + + js = { + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "closed", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": timezone.now(), + "updated_at": timezone.now(), + "article_ids": [1, 2], + }, + # No article, just change of status + "article": { + "sender": "Unsupported Entity", + "internal": False, + "ticket_id": 123, + "created_at": timezone.now(), + "created_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "subject": "New Cookies please", + }, + } + + zp = ZammadParser(js) + + with pytest.raises(ValueError, match="Unsupported scenario"): + zp.action + + +@pytest.mark.django_db +def test_prep_zammad_webhook(): + wh = Webhook.objects.create( + content={ + "ticket": { + "id": 123, + "group": {"id": "123", "name": "TestHelpdesk"}, + "updated_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "title": "Test Ticket Title", + "owner": {"firstname": "Kermit", "lastname": "TheFrog"}, + "state": "open", + "number": "13374321", + "customer": {"firstname": "Cookie", "lastname": "Monster"}, + "created_at": str(timezone.now()), + "updated_at": str(timezone.now()), + "article_ids": [1, 2], + }, + "article": { + "sender": "Agent", + "internal": True, + "ticket_id": 123, + "created_at": str(timezone.now()), + "created_by": {"firstname": "Kermit", "lastname": "TheFrog"}, + "subject": "New Cookies please", + }, + }, + extra={}, + ) + + wh = prep_zammad_webhook(wh) + + assert wh.extra == { + "group": "TestHelpdesk", + "sender": "Kermit", + "action": "new_internal_note", + "message": ( + "TestHelpdesk: Kermit created internal note " + "https://servicedesk.europython.eu/#ticket/zoom/123" + ), + } diff --git a/intbot/tests/test_webhooks.py b/intbot/tests/test_webhooks.py index 9b8ba59..8c6166a 100644 --- a/intbot/tests/test_webhooks.py +++ b/intbot/tests/test_webhooks.py @@ -210,3 +210,8 @@ def test_zammad_webhook_endpoint_works_with_correct_token(client): assert response.json()["status"] == "created" assert response.json()["guid"] == str(wh.uuid) assert wh.source == "zammad" + + +def test_zammad_webhook_endpoint_fails_if_request_not_post(client): + response = client.get("/webhook/zammad/") + assert response.status_code == 405