Skip to content

Commit e622e6b

Browse files
Merge pull request #25 from NETWAYS/fix/zammad-6.5-changes
Fixing Zammad 6.5 API-Changes
2 parents 6348360 + 7fbe80a commit e622e6b

File tree

5 files changed

+199
-21
lines changed

5 files changed

+199
-21
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# notify-zammad
22

3+
> [!IMPORTANT]
4+
> Due to [breaking API changes](https://github.com/zammad/zammad/blob/develop/BREAKING_CHANGES.md#changes-to-search-api-endpoints) this program only works with Zammad > 6.5
5+
36
A notification plugin for (mostly) Icinga which manages problems as Zammad tickets.
47

58
This plugin opens/updates/closes Zammad tickets via the Zammad API. The user/token for this plugin needs at least the `ticket.agent` permission.

cmd/root.go

+11-4
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,16 @@ func sendNotification(_ *cobra.Command, _ []string) {
131131

132132
var notificationErr error
133133

134+
foundTicket := false
135+
134136
if len(tickets) > 0 {
135137
// Using the first ticket found for the notification,
136138
// the SearchTickets methods returns the tickets by created_at.
137139
// If no ticket is found the zammad.Ticket type will be empty,
138140
// which can be used to detect if a new ticket needs to be created.
139141
ticket = tickets[0]
142+
143+
foundTicket = true
140144
}
141145

142146
switch notificationType {
@@ -149,7 +153,7 @@ func sendNotification(_ *cobra.Command, _ []string) {
149153
case icingadsl.Problem:
150154
// Opens a new ticket if none exists
151155
// If one exists, adds article to existing ticket
152-
notificationErr = handleProblemNotification(ctx, c, ticket)
156+
notificationErr = handleProblemNotification(ctx, c, ticket.ID, foundTicket)
153157
case icingadsl.Recovery:
154158
// Closes a ticket if one exists
155159
// If ticket is open, adds article to existing ticket
@@ -206,11 +210,10 @@ func createArticleBody(header string) string {
206210

207211
// handleProblemNotification opens a new ticket if none exists,
208212
// If one exists, adds message to existing ticket.
209-
func handleProblemNotification(ctx context.Context, c *client.Client, ticket zammad.Ticket) error {
213+
func handleProblemNotification(ctx context.Context, c *client.Client, ticketID int, ticketExists bool) error {
210214
var err error
211215

212216
a := zammad.Article{
213-
TicketID: ticket.ID,
214217
Subject: "Problem",
215218
Body: createArticleBody("Problem"),
216219
ContentType: "text/html",
@@ -220,8 +223,10 @@ func handleProblemNotification(ctx context.Context, c *client.Client, ticket zam
220223
}
221224

222225
// If a Zammad Ticket exists, add the article to this ticket.
223-
if ticket.ID != 0 {
226+
if ticketExists {
227+
a.TicketID = ticketID
224228
err = c.AddArticleToTicket(ctx, a)
229+
225230
return err
226231
}
227232

@@ -237,6 +242,8 @@ func handleProblemNotification(ctx context.Context, c *client.Client, ticket zam
237242
title.WriteString(" Service: " + cliConfig.IcingaServiceName)
238243
}
239244

245+
ticket := zammad.NewTicket{}
246+
240247
ticket.Title = title.String()
241248
ticket.Group = cliConfig.ZammadGroup
242249
ticket.Customer = cliConfig.ZammadCustomer

internal/api/api.go

+12-7
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,13 @@ const (
1212
// We currently only care about the assets in which the tickets
1313
// are contained
1414
type TicketSearchResult struct {
15-
Assets Assets `json:"assets"`
16-
}
17-
18-
// Assets represents the assets in the search result
19-
type Assets struct {
20-
Tickets map[string]Ticket `json:"Ticket"`
15+
Tickets []Ticket `json:"Ticket"`
2116
}
2217

2318
// Ticket represents a Zammad Ticket
2419
// We use two custom field attributes for the tickets
2520
// icinga_host and icinga_service to track existing tickets
26-
type Ticket struct {
21+
type NewTicket struct {
2722
ID int `json:"id,omitempty"`
2823
Title string `json:"title"`
2924
Group string `json:"group"`
@@ -33,6 +28,16 @@ type Ticket struct {
3328
Article Article `json:"article,omitempty"`
3429
}
3530

31+
type Ticket struct {
32+
ID int `json:"id,omitempty"`
33+
Title string `json:"title"`
34+
GroupID int `json:"group_id"`
35+
CustomerID int `json:"customer_id"`
36+
IcingaHost string `json:"icinga_host"`
37+
IcingaService string `json:"icinga_service"`
38+
ArticleIDs []int `json:"article_ids,omitempty"`
39+
}
40+
3641
// Article represents a Zammad Ticket Article
3742
type Article struct {
3843
TicketID int `json:"ticket_id,omitempty"`

internal/client/client.go

+19-5
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http"
1313
"net/url"
1414
"strconv"
15+
"strings"
1516

1617
zammad "github.com/NETWAYS/notify_zammad/internal/api"
1718
)
@@ -70,18 +71,31 @@ func (c *Client) SearchTickets(ctx context.Context, hostname, service string) ([
7071
return nil, fmt.Errorf("authentication failed for %s", c.URL.String())
7172
}
7273

73-
var result zammad.TicketSearchResult
74+
var result []zammad.Ticket
7475

75-
err = json.NewDecoder(resp.Body).Decode(&result)
76+
buf := new(strings.Builder)
77+
_, err = io.Copy(buf, resp.Body)
78+
// check errors
79+
if err != nil {
80+
return nil, fmt.Errorf("unable to read search results: %w", err)
81+
}
82+
83+
if buf.String() == "[]" {
84+
tickets := make([]zammad.Ticket, 0)
85+
return tickets, nil
86+
}
87+
88+
err = json.NewDecoder(strings.NewReader(buf.String())).Decode(&result)
7689

7790
if err != nil {
91+
// Maybe an empty result
7892
return nil, fmt.Errorf("unable to parse search results: %w", err)
7993
}
8094

8195
// We only care about the tickets, thus we create a slice to easier work with them
82-
tickets := make([]zammad.Ticket, 0, len(result.Assets.Tickets))
96+
tickets := make([]zammad.Ticket, 0, len(result))
8397

84-
for _, ticket := range result.Assets.Tickets {
98+
for _, ticket := range result {
8599
// If no service is provided we add the ticket and are done
86100
if service == "" {
87101
tickets = append(tickets, ticket)
@@ -133,7 +147,7 @@ func (c *Client) AddArticleToTicket(ctx context.Context, article zammad.Article)
133147
}
134148

135149
// CreateTicket create a new ticket in Zammad
136-
func (c *Client) CreateTicket(ctx context.Context, ticket zammad.Ticket) error {
150+
func (c *Client) CreateTicket(ctx context.Context, ticket zammad.NewTicket) error {
137151
url := c.URL.JoinPath("/api/v1/tickets")
138152

139153
data, err := json.Marshal(ticket)

internal/client/client_test.go

+154-5
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func TestCreateTicket(t *testing.T) {
8383
ctx, cancel := context.WithCancel(context.Background())
8484
defer cancel()
8585

86-
ticket := zammad.Ticket{
86+
ticket := zammad.NewTicket{
8787
Title: "MyNewTicket",
8888
}
8989

@@ -101,7 +101,56 @@ func TestSearchTickets(t *testing.T) {
101101
t.Errorf("Expected GET request, got %s", r.Method)
102102
}
103103
w.WriteHeader(http.StatusOK)
104-
w.Write([]byte(`{ "tickets": [ 4 ], "tickets_count": 1, "assets": { "Ticket": { "4": { "id": 4, "group_id": 1, "priority_id": 2, "state_id": 2, "organization_id": null, "number": "67003", "icinga_host": "MyHost", "icinga_service": "", "title": "Foobar", "owner_id": 1, "customer_id": 4, "note": null, "first_response_at": null, "first_response_escalation_at": null, "first_response_in_min": null, "first_response_diff_in_min": null, "close_at": null, "close_escalation_at": null, "close_in_min": null, "close_diff_in_min": null, "update_escalation_at": null, "update_in_min": null, "update_diff_in_min": null, "last_close_at": null, "last_contact_at": "2024-01-22T10:25:29.671Z", "last_contact_agent_at": null, "last_contact_customer_at": "2024-01-22T10:25:29.671Z", "last_owner_update_at": null, "create_article_type_id": 5, "create_article_sender_id": 2, "article_count": 2, "escalation_at": null, "pending_time": null, "type": null, "time_unit": null, "preferences": {}, "updated_by_id": 3, "created_by_id": 3, "created_at": "2024-01-22T10:25:29.632Z", "updated_at": "2024-01-22T10:26:43.716Z", "article_ids": [ 4, 3 ], "ticket_time_accounting_ids": [] } } }}`))
104+
w.Write([]byte(`[
105+
{
106+
"id": 13,
107+
"group_id": 1,
108+
"priority_id": 2,
109+
"state_id": 1,
110+
"organization_id": null,
111+
"number": "65012",
112+
"title": "[Problem] State: Down for Host: MyHost",
113+
"owner_id": 1,
114+
"customer_id": 3,
115+
"note": null,
116+
"first_response_at": null,
117+
"first_response_escalation_at": null,
118+
"first_response_in_min": null,
119+
"first_response_diff_in_min": null,
120+
"close_at": null,
121+
"close_escalation_at": null,
122+
"close_in_min": null,
123+
"close_diff_in_min": null,
124+
"update_escalation_at": null,
125+
"update_in_min": null,
126+
"update_diff_in_min": null,
127+
"last_close_at": null,
128+
"last_contact_at": null,
129+
"last_contact_agent_at": null,
130+
"last_contact_customer_at": null,
131+
"last_owner_update_at": null,
132+
"create_article_type_id": 11,
133+
"create_article_sender_id": 1,
134+
"article_count": 1,
135+
"escalation_at": null,
136+
"pending_time": null,
137+
"type": null,
138+
"time_unit": null,
139+
"preferences": {},
140+
"updated_by_id": 3,
141+
"created_by_id": 3,
142+
"created_at": "2025-05-05T09:38:25.350Z",
143+
"updated_at": "2025-05-05T09:38:25.418Z",
144+
"checklist_id": null,
145+
"icinga_host": "MyHost",
146+
"icinga_service": "",
147+
"referencing_checklist_ids": [],
148+
"article_ids": [
149+
21
150+
],
151+
"ticket_time_accounting_ids": []
152+
}
153+
]`))
105154
}))
106155

107156
defer ts.Close()
@@ -120,7 +169,7 @@ func TestSearchTickets(t *testing.T) {
120169
tickets, err := c.SearchTickets(ctx, "MyHost", "")
121170

122171
if err != nil {
123-
t.Errorf("Did not except error: %v", err)
172+
t.Errorf("Did not expect error: %v", err)
124173
}
125174

126175
if len(tickets) < 1 {
@@ -139,7 +188,58 @@ func TestSearchTicketsWithNoService(t *testing.T) {
139188
t.Errorf("Expected GET request, got %s", r.Method)
140189
}
141190
w.WriteHeader(http.StatusOK)
142-
w.Write([]byte(`{ "tickets": [ 4 ], "tickets_count": 1, "assets": { "Ticket": { "4": { "id": 4, "group_id": 1, "priority_id": 2, "state_id": 2, "organization_id": null, "number": "67003", "icinga_host": "MyHost", "icinga_service": "NoSuchService", "title": "Foobar", "owner_id": 1, "customer_id": 4, "note": null, "first_response_at": null, "first_response_escalation_at": null, "first_response_in_min": null, "first_response_diff_in_min": null, "close_at": null, "close_escalation_at": null, "close_in_min": null, "close_diff_in_min": null, "update_escalation_at": null, "update_in_min": null, "update_diff_in_min": null, "last_close_at": null, "last_contact_at": "2024-01-22T10:25:29.671Z", "last_contact_agent_at": null, "last_contact_customer_at": "2024-01-22T10:25:29.671Z", "last_owner_update_at": null, "create_article_type_id": 5, "create_article_sender_id": 2, "article_count": 2, "escalation_at": null, "pending_time": null, "type": null, "time_unit": null, "preferences": {}, "updated_by_id": 3, "created_by_id": 3, "created_at": "2024-01-22T10:25:29.632Z", "updated_at": "2024-01-22T10:26:43.716Z", "article_ids": [ 4, 3 ], "ticket_time_accounting_ids": [] } } }}`))
191+
w.Write([]byte(`[
192+
{
193+
"id": 15,
194+
"group_id": 1,
195+
"priority_id": 2,
196+
"state_id": 1,
197+
"organization_id": null,
198+
"number": "65014",
199+
"title": "[Problem] State: Down for Host: MyHost Service: NoSuchService",
200+
"owner_id": 1,
201+
"customer_id": 3,
202+
"note": null,
203+
"first_response_at": null,
204+
"first_response_escalation_at": null,
205+
"first_response_in_min": null,
206+
"first_response_diff_in_min": null,
207+
"close_at": null,
208+
"close_escalation_at": null,
209+
"close_in_min": null,
210+
"close_diff_in_min": null,
211+
"update_escalation_at": null,
212+
"update_in_min": null,
213+
"update_diff_in_min": null,
214+
"last_close_at": null,
215+
"last_contact_at": null,
216+
"last_contact_agent_at": null,
217+
"last_contact_customer_at": null,
218+
"last_owner_update_at": null,
219+
"create_article_type_id": 11,
220+
"create_article_sender_id": 1,
221+
"article_count": 3,
222+
"escalation_at": null,
223+
"pending_time": null,
224+
"type": null,
225+
"time_unit": null,
226+
"preferences": {},
227+
"updated_by_id": 3,
228+
"created_by_id": 3,
229+
"created_at": "2025-05-05T12:52:36.650Z",
230+
"updated_at": "2025-05-05T13:08:29.288Z",
231+
"checklist_id": null,
232+
"icinga_host": "MyHost",
233+
"icinga_service": "NoSuchService",
234+
"referencing_checklist_ids": [],
235+
"article_ids": [
236+
33,
237+
34,
238+
35
239+
],
240+
"ticket_time_accounting_ids": []
241+
}
242+
]`))
143243
}))
144244

145245
defer ts.Close()
@@ -173,7 +273,56 @@ func TestSearchTicketsWithService(t *testing.T) {
173273
t.Errorf("Expected GET request, got %s", r.Method)
174274
}
175275
w.WriteHeader(http.StatusOK)
176-
w.Write([]byte(`{ "tickets": [ 4 ], "tickets_count": 1, "assets": { "Ticket": { "4": { "id": 4, "group_id": 1, "priority_id": 2, "state_id": 2, "organization_id": null, "number": "67003", "icinga_host": "MyHost", "icinga_service": "MyService", "title": "Foobar", "owner_id": 1, "customer_id": 4, "note": null, "first_response_at": null, "first_response_escalation_at": null, "first_response_in_min": null, "first_response_diff_in_min": null, "close_at": null, "close_escalation_at": null, "close_in_min": null, "close_diff_in_min": null, "update_escalation_at": null, "update_in_min": null, "update_diff_in_min": null, "last_close_at": null, "last_contact_at": "2024-01-22T10:25:29.671Z", "last_contact_agent_at": null, "last_contact_customer_at": "2024-01-22T10:25:29.671Z", "last_owner_update_at": null, "create_article_type_id": 5, "create_article_sender_id": 2, "article_count": 2, "escalation_at": null, "pending_time": null, "type": null, "time_unit": null, "preferences": {}, "updated_by_id": 3, "created_by_id": 3, "created_at": "2024-01-22T10:25:29.632Z", "updated_at": "2024-01-22T10:26:43.716Z", "article_ids": [ 4, 3 ], "ticket_time_accounting_ids": [] } } }}`))
276+
w.Write([]byte(`[
277+
{
278+
"id": 16,
279+
"group_id": 1,
280+
"priority_id": 2,
281+
"state_id": 1,
282+
"organization_id": null,
283+
"number": "65015",
284+
"title": "[Problem] State: Down for Host: MyHost Service: MyService",
285+
"owner_id": 1,
286+
"customer_id": 3,
287+
"note": null,
288+
"first_response_at": null,
289+
"first_response_escalation_at": null,
290+
"first_response_in_min": null,
291+
"first_response_diff_in_min": null,
292+
"close_at": null,
293+
"close_escalation_at": null,
294+
"close_in_min": null,
295+
"close_diff_in_min": null,
296+
"update_escalation_at": null,
297+
"update_in_min": null,
298+
"update_diff_in_min": null,
299+
"last_close_at": null,
300+
"last_contact_at": null,
301+
"last_contact_agent_at": null,
302+
"last_contact_customer_at": null,
303+
"last_owner_update_at": null,
304+
"create_article_type_id": 11,
305+
"create_article_sender_id": 1,
306+
"article_count": 1,
307+
"escalation_at": null,
308+
"pending_time": null,
309+
"type": null,
310+
"time_unit": null,
311+
"preferences": {},
312+
"updated_by_id": 3,
313+
"created_by_id": 3,
314+
"created_at": "2025-05-05T13:46:37.651Z",
315+
"updated_at": "2025-05-05T13:46:37.733Z",
316+
"checklist_id": null,
317+
"icinga_host": "MyHost",
318+
"icinga_service": "MyService",
319+
"referencing_checklist_ids": [],
320+
"article_ids": [
321+
36
322+
],
323+
"ticket_time_accounting_ids": []
324+
}
325+
]`))
177326
}))
178327

179328
defer ts.Close()

0 commit comments

Comments
 (0)