Skip to content

Commit 895d455

Browse files
authored
Zammad Discord Router v1
PoC of sending notifications from zammad to specific discord channels - depending on which group/queue they are part of.
1 parent 80eea54 commit 895d455

File tree

10 files changed

+569
-7
lines changed

10 files changed

+569
-7
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ test:
5959
$(TEST_CMD) -s -v
6060

6161
test_last_failed:
62-
$(TEST_CMD) -s -v --last-failed
62+
$(TEST_CMD) -s -vv --last-failed
6363

6464
test_: test_last_failed
6565

deploy/templates/app/intbot.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ GITHUB_WEBHOOK_SECRET_TOKEN="github-webhook-secret-token"
3434
GITHUB_BOARD_PROJECT_ID="GITHUB_BOARD_PROJECT_ID"
3535
GITHUB_EP2025_PROJECT_ID="GITHUB_EP2025_PROJECT_ID"
3636
GITHUB_EM_PROJECT_ID="GITHUB_EM_PROJECT_ID"
37+
38+
# Zammad
39+
ZAMMAD_WEBHOOK_SECRET_TOKEN="zammad-shared-secret-goes-here"
40+
ZAMMAD_GROUP_BILLING="zammad-billing-group-name-goes-here"
41+
ZAMMAD_GROUP_HELPDESK="zammad-helpdesk-group-name-goes-here"

intbot/core/bot/channel_router.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
GithubRepositories,
1010
parse_github_webhook,
1111
)
12+
from core.integrations.zammad import ZammadConfig
1213
from core.models import Webhook
1314
from django.conf import settings
1415

@@ -23,6 +24,7 @@ class DiscordChannel:
2324

2425

2526
class Channels:
27+
# Github
2628
test_channel = DiscordChannel(
2729
channel_id=settings.DISCORD_TEST_CHANNEL_ID,
2830
channel_name=settings.DISCORD_TEST_CHANNEL_NAME,
@@ -49,11 +51,24 @@ class Channels:
4951
channel_name=settings.DISCORD_BOT_CHANNEL_NAME,
5052
)
5153

54+
# Zammad
55+
billing_channel = DiscordChannel(
56+
channel_id=settings.DISCORD_BILLING_CHANNEL_ID,
57+
channel_name=settings.DISCORD_BILLING_CHANNEL_NAME,
58+
)
59+
helpdesk_channel = DiscordChannel(
60+
channel_id=settings.DISCORD_HELPDESK_CHANNEL_ID,
61+
channel_name=settings.DISCORD_HELPDESK_CHANNEL_NAME,
62+
)
63+
5264

5365
def discord_channel_router(wh: Webhook) -> DiscordChannel:
5466
if wh.source == "github":
5567
return github_router(wh)
5668

69+
elif wh.source == "zammad":
70+
return zammad_router(wh)
71+
5772
elif wh.source == "internal":
5873
return internal_router(wh)
5974

@@ -91,6 +106,19 @@ def github_router(wh: Webhook) -> DiscordChannel:
91106
return dont_send_it
92107

93108

109+
def zammad_router(wh: Webhook) -> DiscordChannel:
110+
groups = {
111+
ZammadConfig.helpdesk_group: Channels.helpdesk_channel,
112+
ZammadConfig.billing_group: Channels.billing_channel,
113+
}
114+
115+
if channel := groups.get(wh.extra["group"]):
116+
return channel
117+
118+
# If it doesn't match any of the groups, just skip it
119+
return dont_send_it
120+
121+
94122
def internal_router(wh: Webhook) -> DiscordChannel:
95123
# For now just send all the internal messages to a test channel
96124
return Channels.test_channel

intbot/core/endpoints/webhooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def zammad_webhook_endpoint(request):
117117
process_webhook.enqueue(str(wh.uuid))
118118
return JsonResponse({"status": "created", "guid": wh.uuid})
119119

120-
return HttpResponseNotAllowed("Only POST")
120+
return HttpResponseNotAllowed(permitted_methods=["POST"])
121121

122122

123123
def verify_zammad_signature(request) -> str:

intbot/core/integrations/zammad.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
from datetime import datetime
2+
from django.conf import settings
3+
4+
from core.models import Webhook
5+
from pydantic import BaseModel
6+
7+
8+
class ZammadConfig:
9+
url = settings.ZAMMAD_URL # servicedesk.europython.eu
10+
billing_group = settings.ZAMMAD_GROUP_BILLING
11+
helpdesk_group = settings.ZAMMAD_GROUP_HELPDESK
12+
13+
class ZammadGroup(BaseModel):
14+
id: int
15+
name: str
16+
17+
18+
class ZammadUser(BaseModel):
19+
firstname: str
20+
lastname: str
21+
22+
23+
class ZammadTicket(BaseModel):
24+
id: int
25+
group: ZammadGroup
26+
title: str
27+
owner: ZammadUser
28+
state: str
29+
number: str
30+
customer: ZammadUser
31+
created_at: datetime
32+
updated_at: datetime
33+
updated_by: ZammadUser
34+
article_ids: list[int]
35+
36+
37+
class ZammadArticle(BaseModel):
38+
sender: str
39+
internal: bool
40+
ticket_id: int
41+
created_at: datetime
42+
created_by: ZammadUser
43+
subject: str
44+
45+
46+
class ZammadWebhook(BaseModel):
47+
ticket: ZammadTicket
48+
article: ZammadArticle | None
49+
50+
51+
JsonType = dict[str, str | int | float | list | dict]
52+
53+
54+
class ZammadParser:
55+
56+
class Actions:
57+
new_ticket_created = "new_ticket_created"
58+
new_message_in_thread = "new_message_in_thread"
59+
replied_in_thread = "replied_in_thread"
60+
new_internal_note = "new_internal_note"
61+
updated_ticket = "updated_ticket"
62+
63+
def __init__(self, content: JsonType):
64+
self.content = content
65+
# Ticket is always there, article is optional
66+
# Example: change of status of the Ticket doesn't contain article
67+
self.ticket = ZammadTicket.model_validate(self.content["ticket"])
68+
self.article = (
69+
ZammadArticle.model_validate(self.content["article"])
70+
if self.content["article"]
71+
else None
72+
)
73+
74+
@property
75+
def action(self):
76+
"""
77+
Zammad doesn't give us an action inside the webhook, so we can either
78+
set custom triggers and URLs for every action, or we can try to infer
79+
the action from the content of the webhook. For simplicity of the
80+
overall setup, we are implementing the latter here.
81+
82+
"New Ticket created"? -- has article, and len(article_ids) == 1
83+
-- state change will not have article associated with it.
84+
"New message in the thread" -- article, sender==Customer
85+
"We sent a new reply in the thread" -- article, sender==Agent
86+
"New internal note in the thread" -- article, internal==true
87+
"Updated the ticket ...", -- updated_by.firstname
88+
"""
89+
# Implementing this as cascading if statements here is part of the
90+
# assumptions.
91+
# For example the "sender == Customer" is going to be True also for their
92+
# first message that originally creates the ticket. However first time
93+
# we get a message, we will return "New ticket" and second time "New
94+
# message in the thread".
95+
if self.article:
96+
if len(self.ticket.article_ids) == 1:
97+
# This means we have an article, and it's a first one, therefore a
98+
# ticket is new.
99+
return self.Actions.new_ticket_created
100+
101+
elif self.article.internal is True:
102+
return self.Actions.new_internal_note
103+
104+
elif self.article.sender == "Customer":
105+
return self.Actions.new_message_in_thread
106+
107+
elif self.article.sender == "Agent":
108+
return self.Actions.replied_in_thread
109+
110+
elif not self.article:
111+
return self.Actions.updated_ticket
112+
113+
raise ValueError("Unsupported scenario")
114+
115+
@property
116+
def updated_by(self):
117+
return self.ticket.updated_by.firstname
118+
119+
@property
120+
def group(self):
121+
return self.ticket.group.name
122+
123+
@property
124+
def url(self):
125+
return f"https://{ZammadConfig.url}/#ticket/zoom/{self.ticket.id}"
126+
127+
def to_discord_message(self):
128+
message = "{group}: {sender} {action} {details}".format
129+
130+
# Action
131+
actions = {
132+
self.Actions.new_ticket_created: "created new ticket",
133+
self.Actions.new_message_in_thread: "sent a new message",
134+
self.Actions.replied_in_thread: "replied to a ticket",
135+
self.Actions.new_internal_note: "created internal note",
136+
self.Actions.updated_ticket: "updated ticket",
137+
}
138+
139+
action = actions[self.action]
140+
141+
return message(group=self.group, sender=self.updated_by, action=action, details=self.url)
142+
143+
def meta(self):
144+
return {
145+
"group": self.group,
146+
"sender": self.updated_by,
147+
"action": self.action,
148+
"message": self.to_discord_message(),
149+
}
150+
151+
152+
def prep_zammad_webhook(wh: Webhook):
153+
"""Parse and store some information for later"""
154+
zp = ZammadParser(wh.content)
155+
156+
wh.event = zp.action
157+
wh.extra = zp.meta()
158+
159+
wh.save()
160+
161+
return wh

intbot/core/tasks.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from core.integrations.github import parse_github_webhook, prep_github_webhook
4+
from core.integrations.zammad import prep_zammad_webhook
45
from core.bot.channel_router import discord_channel_router, dont_send_it
56
from core.models import DiscordMessage, Webhook
67
from django.utils import timezone
@@ -58,6 +59,8 @@ def process_github_webhook(wh: Webhook):
5859
channel = discord_channel_router(wh)
5960

6061
if channel == dont_send_it:
62+
# Mark as processed, to avoid re-processing in the future if we
63+
# shouldn't send a message.
6164
wh.processed_at = timezone.now()
6265
wh.save()
6366
return
@@ -66,14 +69,37 @@ def process_github_webhook(wh: Webhook):
6669
channel_id=channel.channel_id,
6770
channel_name=channel.channel_name,
6871
content=f"GitHub: {parsed.as_discord_message()}",
69-
# Mark as unsend - to be sent with the next batch
72+
# Mark as unsent - to be sent with the next batch
7073
sent_at=None,
7174
)
7275
wh.processed_at = timezone.now()
7376
wh.save()
7477

7578

7679
def process_zammad_webhook(wh: Webhook):
77-
# NOTE(artcz) Do nothing for now. Just a placeholder.
78-
# Processing will come in the next PR.
79-
return
80+
if wh.source != "zammad":
81+
raise ValueError("Incorrect wh.source = {wh.source}")
82+
83+
# Unlike in github, the zammad webhook is richer and
84+
# contains much more information, so no extra fetch is needed.
85+
# However, we can extract information and store it in the meta field, that
86+
# way we can reuse it later more easily.
87+
wh = prep_zammad_webhook(wh)
88+
channel = discord_channel_router(wh)
89+
90+
if channel == dont_send_it:
91+
# Mark as processed, to avoid re-processing in the future if we
92+
# shouldn't send a message.
93+
wh.processed_at = timezone.now()
94+
wh.save()
95+
return
96+
97+
DiscordMessage.objects.create(
98+
channel_id=channel.channel_id,
99+
channel_name=channel.channel_name,
100+
content=f"Zammad: {wh.meta['message']}",
101+
# Mark as unsent - to be sent with the next batch
102+
sent_at=None,
103+
)
104+
wh.processed_at = timezone.now()
105+
wh.save()

intbot/intbot/settings.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ def get(name) -> str:
159159
DISCORD_BOT_CHANNEL_ID = get("DISCORD_BOT_CHANNEL_ID")
160160
DISCORD_BOT_CHANNEL_NAME = get("DISCORD_BOT_CHANNEL_NAME")
161161

162+
DISCORD_HELPDESK_CHANNEL_ID = get("DISCORD_HELPDESK_CHANNEL_ID")
163+
DISCORD_HELPDESK_CHANNEL_NAME = get("DISCORD_HELPDESK_CHANNEL_NAME")
164+
DISCORD_BILLING_CHANNEL_ID = get("DISCORD_BILLING_CHANNEL_ID")
165+
DISCORD_BILLING_CHANNEL_NAME = get("DISCORD_BILLING_CHANNEL_NAME")
166+
162167
# Github
163168
GITHUB_API_TOKEN = get("GITHUB_API_TOKEN")
164169
GITHUB_WEBHOOK_SECRET_TOKEN = get("GITHUB_WEBHOOK_SECRET_TOKEN")
@@ -170,6 +175,12 @@ def get(name) -> str:
170175
# Zammad
171176
ZAMMAD_WEBHOOK_SECRET_TOKEN = get("ZAMMAD_WEBHOOK_SECRET_TOKEN")
172177

178+
ZAMMAD_URL = "servicedesk.europython.eu"
179+
ZAMMAD_GROUP_BILLING = get("ZAMMAD_GROUP_BILLING")
180+
ZAMMAD_GROUP_HELPDESK = get("ZAMMAD_GROUP_HELPDESK")
181+
182+
183+
173184
if DJANGO_ENV == "dev":
174185
DEBUG = True
175186
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
@@ -246,6 +257,16 @@ def get(name) -> str:
246257
DISCORD_EM_CHANNEL_NAME = "em_channel"
247258
DISCORD_EM_CHANNEL_ID = "123123"
248259

260+
DISCORD_HELPDESK_CHANNEL_ID = "1237777"
261+
DISCORD_HELPDESK_CHANNEL_NAME = "helpdesk_channel"
262+
DISCORD_BILLING_CHANNEL_ID = "123999"
263+
DISCORD_BILLING_CHANNEL_NAME = "billing_channel"
264+
265+
ZAMMAD_GROUP_HELPDESK = "TestZammad Helpdesk"
266+
ZAMMAD_GROUP_BILLING = "TestZammad Billing"
267+
268+
269+
249270

250271
elif DJANGO_ENV == "local_container":
251272
DEBUG = False

0 commit comments

Comments
 (0)