Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions deploy/templates/app/intbot.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
28 changes: 28 additions & 0 deletions intbot/core/bot/channel_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
GithubRepositories,
parse_github_webhook,
)
from core.integrations.zammad import ZammadGroups
from core.models import Webhook
from django.conf import settings

Expand All @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -91,6 +106,19 @@ def github_router(wh: Webhook) -> DiscordChannel:
return dont_send_it


def zammad_router(wh: Webhook) -> DiscordChannel:
groups = {
ZammadGroups.helpdesk: Channels.helpdesk_channel,
ZammadGroups.billing: 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
2 changes: 1 addition & 1 deletion intbot/core/endpoints/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
160 changes: 160 additions & 0 deletions intbot/core/integrations/zammad.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from datetime import datetime
from django.conf import settings

from core.models import Webhook
from pydantic import BaseModel


class ZammadGroups:
billing = settings.ZAMMAD_GROUP_BILLING
helpdesk = 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 and 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 and self.article.internal is True:
return self.Actions.new_internal_note

elif self.article and self.article.sender == "Customer":
return self.Actions.new_message_in_thread

elif self.article and 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://servicedesk.europython.eu/#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
30 changes: 26 additions & 4 deletions intbot/core/tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -66,14 +67,35 @@ 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()
wh.save()


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:
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()
20 changes: 20 additions & 0 deletions intbot/intbot/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -170,6 +175,11 @@ def get(name) -> str:
# Zammad
ZAMMAD_WEBHOOK_SECRET_TOKEN = get("ZAMMAD_WEBHOOK_SECRET_TOKEN")

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"]
Expand Down Expand Up @@ -246,6 +256,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
Expand Down
Loading