Skip to content

Commit 91a48c4

Browse files
committed
v1 of zammad discord router
1 parent 80eea54 commit 91a48c4

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 ZammadGroups
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+
ZammadGroups.helpdesk: Channels.helpdesk_channel,
112+
ZammadGroups.billing: 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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ def zammad_webhook_endpoint(request):
107107

108108
wh = Webhook.objects.create(
109109
source="zammad",
110+
# Because the webhooks just send full objects without indication
111+
# what changed, or what triggered the action, we will custom URLs
112+
# for different types of actions.
113+
# In other words – how the webhook is processed on the backend
114+
# depends the Trigger configuration in zammad.
115+
# action=action,
110116
meta=zammad_headers,
111117
signature=signature,
112118
content=json.loads(request.body),
@@ -117,7 +123,7 @@ def zammad_webhook_endpoint(request):
117123
process_webhook.enqueue(str(wh.uuid))
118124
return JsonResponse({"status": "created", "guid": wh.uuid})
119125

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

122128

123129
def verify_zammad_signature(request) -> str:

intbot/core/integrations/zammad.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 ZammadGroups:
9+
billing = settings.ZAMMAD_GROUP_BILLING
10+
helpdesk = settings.ZAMMAD_GROUP_HELPDESK
11+
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 simplcity 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 and len(self.ticket.article_ids) == 1:
96+
# This means we have an article, and it's a first one, therefore a
97+
# ticket is new.
98+
return self.Actions.new_ticket_created
99+
100+
elif self.article and self.article.internal is True:
101+
return self.Actions.new_internal_note
102+
103+
elif self.article and self.article.sender == "Customer":
104+
return self.Actions.new_message_in_thread
105+
106+
elif self.article and self.article.sender == "Agent":
107+
return self.Actions.replied_in_thread
108+
109+
elif not self.article:
110+
return self.Actions.updated_ticket
111+
112+
raise ValueError("Unsupported scenario")
113+
114+
@property
115+
def updated_by(self):
116+
return self.ticket.updated_by.firstname
117+
118+
@property
119+
def group(self):
120+
return self.ticket.group.name
121+
122+
@property
123+
def url(self):
124+
return f"https://servicedesk.europython.eu/#ticket/zoom/{self.ticket.id}"
125+
126+
def to_discord_message(self):
127+
message = "{group}: {sender} {action} {details}".format
128+
129+
# Action
130+
actions = {
131+
self.Actions.new_ticket_created: "created new ticket",
132+
self.Actions.new_message_in_thread: "sent a new message",
133+
self.Actions.replied_in_thread: "replied to a ticket",
134+
self.Actions.new_internal_note: "created internal note",
135+
self.Actions.updated_ticket: "updated ticket",
136+
}
137+
138+
action = actions[self.action]
139+
140+
return message(group=self.group, sender=self.updated_by, action=action, details=self.url)
141+
142+
def meta(self):
143+
return {
144+
"group": self.group,
145+
"sender": self.updated_by,
146+
"action": self.action,
147+
"message": self.to_discord_message(),
148+
}
149+
150+
151+
def prep_zammad_webhook(wh: Webhook):
152+
"""Parse and store some information for later"""
153+
zp = ZammadParser(wh.content)
154+
155+
wh.event = zp.action
156+
wh.extra = zp.meta()
157+
158+
wh.save()
159+
160+
return wh

intbot/core/tasks.py

Lines changed: 26 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
@@ -66,14 +67,35 @@ def process_github_webhook(wh: Webhook):
6667
channel_id=channel.channel_id,
6768
channel_name=channel.channel_name,
6869
content=f"GitHub: {parsed.as_discord_message()}",
69-
# Mark as unsend - to be sent with the next batch
70+
# Mark as unsent - to be sent with the next batch
7071
sent_at=None,
7172
)
7273
wh.processed_at = timezone.now()
7374
wh.save()
7475

7576

7677
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
78+
if wh.source != "zammad":
79+
raise ValueError("Incorrect wh.source = {wh.source}")
80+
81+
# Unlike in github, the zammad webhook is richer and
82+
# contains much more information, so no extra fetch is needed.
83+
# However, we can extract information and store it in the meta field, that
84+
# way we can reuse it later more easily.
85+
wh = prep_zammad_webhook(wh)
86+
channel = discord_channel_router(wh)
87+
88+
if channel == dont_send_it:
89+
wh.processed_at = timezone.now()
90+
wh.save()
91+
return
92+
93+
DiscordMessage.objects.create(
94+
channel_id=channel.channel_id,
95+
channel_name=channel.channel_name,
96+
content=f"Zammad: {wh.meta['message']}",
97+
# Mark as unsent - to be sent with the next batch
98+
sent_at=None,
99+
)
100+
wh.processed_at = timezone.now()
101+
wh.save()

intbot/intbot/settings.py

Lines changed: 20 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,11 @@ def get(name) -> str:
170175
# Zammad
171176
ZAMMAD_WEBHOOK_SECRET_TOKEN = get("ZAMMAD_WEBHOOK_SECRET_TOKEN")
172177

178+
ZAMMAD_GROUP_BILLING = get("ZAMMAD_GROUP_BILLING")
179+
ZAMMAD_GROUP_HELPDESK = get("ZAMMAD_GROUP_HELPDESK")
180+
181+
182+
173183
if DJANGO_ENV == "dev":
174184
DEBUG = True
175185
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
@@ -246,6 +256,16 @@ def get(name) -> str:
246256
DISCORD_EM_CHANNEL_NAME = "em_channel"
247257
DISCORD_EM_CHANNEL_ID = "123123"
248258

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

250270
elif DJANGO_ENV == "local_container":
251271
DEBUG = False

0 commit comments

Comments
 (0)