Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# notify-zammad

> [!IMPORTANT]
> 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

A notification plugin for (mostly) Icinga which manages problems as Zammad tickets.

This plugin opens/updates/closes Zammad tickets via the Zammad API. The user/token for this plugin needs at least the `ticket.agent` permission.
Expand Down
15 changes: 11 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,16 @@ func sendNotification(_ *cobra.Command, _ []string) {

var notificationErr error

foundTicket := false

if len(tickets) > 0 {
// Using the first ticket found for the notification,
// the SearchTickets methods returns the tickets by created_at.
// If no ticket is found the zammad.Ticket type will be empty,
// which can be used to detect if a new ticket needs to be created.
ticket = tickets[0]

foundTicket = true
}

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

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

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

// If a Zammad Ticket exists, add the article to this ticket.
if ticket.ID != 0 {
if ticketExists {
a.TicketID = ticketID
err = c.AddArticleToTicket(ctx, a)

return err
}

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

ticket := zammad.NewTicket{}

ticket.Title = title.String()
ticket.Group = cliConfig.ZammadGroup
ticket.Customer = cliConfig.ZammadCustomer
Expand Down
19 changes: 12 additions & 7 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,13 @@ const (
// We currently only care about the assets in which the tickets
// are contained
type TicketSearchResult struct {
Assets Assets `json:"assets"`
}

// Assets represents the assets in the search result
type Assets struct {
Tickets map[string]Ticket `json:"Ticket"`
Tickets []Ticket `json:"Ticket"`
}

// Ticket represents a Zammad Ticket
// We use two custom field attributes for the tickets
// icinga_host and icinga_service to track existing tickets
type Ticket struct {
type NewTicket struct {
ID int `json:"id,omitempty"`
Title string `json:"title"`
Group string `json:"group"`
Expand All @@ -33,6 +28,16 @@ type Ticket struct {
Article Article `json:"article,omitempty"`
}

type Ticket struct {
ID int `json:"id,omitempty"`
Title string `json:"title"`
GroupID int `json:"group_id"`
CustomerID int `json:"customer_id"`
IcingaHost string `json:"icinga_host"`
IcingaService string `json:"icinga_service"`
ArticleIDs []int `json:"article_ids,omitempty"`
}

// Article represents a Zammad Ticket Article
type Article struct {
TicketID int `json:"ticket_id,omitempty"`
Expand Down
24 changes: 19 additions & 5 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"

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

var result zammad.TicketSearchResult
var result []zammad.Ticket

err = json.NewDecoder(resp.Body).Decode(&result)
buf := new(strings.Builder)
_, err = io.Copy(buf, resp.Body)
// check errors
if err != nil {
return nil, fmt.Errorf("unable to read search results: %w", err)
}

if buf.String() == "[]" {
tickets := make([]zammad.Ticket, 0)
return tickets, nil
}

err = json.NewDecoder(strings.NewReader(buf.String())).Decode(&result)

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

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

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

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

data, err := json.Marshal(ticket)
Expand Down
159 changes: 154 additions & 5 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestCreateTicket(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ticket := zammad.Ticket{
ticket := zammad.NewTicket{
Title: "MyNewTicket",
}

Expand All @@ -101,7 +101,56 @@ func TestSearchTickets(t *testing.T) {
t.Errorf("Expected GET request, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
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": [] } } }}`))
w.Write([]byte(`[
{
"id": 13,
"group_id": 1,
"priority_id": 2,
"state_id": 1,
"organization_id": null,
"number": "65012",
"title": "[Problem] State: Down for Host: MyHost",
"owner_id": 1,
"customer_id": 3,
"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": null,
"last_contact_agent_at": null,
"last_contact_customer_at": null,
"last_owner_update_at": null,
"create_article_type_id": 11,
"create_article_sender_id": 1,
"article_count": 1,
"escalation_at": null,
"pending_time": null,
"type": null,
"time_unit": null,
"preferences": {},
"updated_by_id": 3,
"created_by_id": 3,
"created_at": "2025-05-05T09:38:25.350Z",
"updated_at": "2025-05-05T09:38:25.418Z",
"checklist_id": null,
"icinga_host": "MyHost",
"icinga_service": "",
"referencing_checklist_ids": [],
"article_ids": [
21
],
"ticket_time_accounting_ids": []
}
]`))
}))

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

if err != nil {
t.Errorf("Did not except error: %v", err)
t.Errorf("Did not expect error: %v", err)
}

if len(tickets) < 1 {
Expand All @@ -139,7 +188,58 @@ func TestSearchTicketsWithNoService(t *testing.T) {
t.Errorf("Expected GET request, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
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": [] } } }}`))
w.Write([]byte(`[
{
"id": 15,
"group_id": 1,
"priority_id": 2,
"state_id": 1,
"organization_id": null,
"number": "65014",
"title": "[Problem] State: Down for Host: MyHost Service: NoSuchService",
"owner_id": 1,
"customer_id": 3,
"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": null,
"last_contact_agent_at": null,
"last_contact_customer_at": null,
"last_owner_update_at": null,
"create_article_type_id": 11,
"create_article_sender_id": 1,
"article_count": 3,
"escalation_at": null,
"pending_time": null,
"type": null,
"time_unit": null,
"preferences": {},
"updated_by_id": 3,
"created_by_id": 3,
"created_at": "2025-05-05T12:52:36.650Z",
"updated_at": "2025-05-05T13:08:29.288Z",
"checklist_id": null,
"icinga_host": "MyHost",
"icinga_service": "NoSuchService",
"referencing_checklist_ids": [],
"article_ids": [
33,
34,
35
],
"ticket_time_accounting_ids": []
}
]`))
}))

defer ts.Close()
Expand Down Expand Up @@ -173,7 +273,56 @@ func TestSearchTicketsWithService(t *testing.T) {
t.Errorf("Expected GET request, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
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": [] } } }}`))
w.Write([]byte(`[
{
"id": 16,
"group_id": 1,
"priority_id": 2,
"state_id": 1,
"organization_id": null,
"number": "65015",
"title": "[Problem] State: Down for Host: MyHost Service: MyService",
"owner_id": 1,
"customer_id": 3,
"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": null,
"last_contact_agent_at": null,
"last_contact_customer_at": null,
"last_owner_update_at": null,
"create_article_type_id": 11,
"create_article_sender_id": 1,
"article_count": 1,
"escalation_at": null,
"pending_time": null,
"type": null,
"time_unit": null,
"preferences": {},
"updated_by_id": 3,
"created_by_id": 3,
"created_at": "2025-05-05T13:46:37.651Z",
"updated_at": "2025-05-05T13:46:37.733Z",
"checklist_id": null,
"icinga_host": "MyHost",
"icinga_service": "MyService",
"referencing_checklist_ids": [],
"article_ids": [
36
],
"ticket_time_accounting_ids": []
}
]`))
}))

defer ts.Close()
Expand Down