File: notifiarr.go

package info (click to toggle)
golang-github-nicholas-fedor-shoutrrr 0.12.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 5,680 kB
  • sloc: sh: 74; makefile: 58
file content (440 lines) | stat: -rw-r--r-- 12,267 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
package notifiarr

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"strings"

	"github.com/nicholas-fedor/shoutrrr/pkg/format"
	"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
	"github.com/nicholas-fedor/shoutrrr/pkg/types"
)

const (
	// APIBaseURL is the base URL for Notifiarr API.
	APIBaseURL = "https://notifiarr.com/api/v1/notification/passthrough"
	// mentionTypeNone represents no mention type.
	mentionTypeNone = 0
	// mentionTypeUser represents a user mention type.
	mentionTypeUser = 1
	// mentionTypeRole represents a role mention type.
	mentionTypeRole = 2
)

// ErrSendFailed indicates a failure to send a notification to Notifiarr.
var (
	ErrSendFailed = errors.New("failed to send notification to Notifiarr")
	// ErrUnexpectedStatus indicates the server returned an unexpected response status code.
	ErrUnexpectedStatus = errors.New("server returned unexpected response status code")
	// ErrInvalidAPIKey indicates an invalid API key was provided.
	ErrInvalidAPIKey = errors.New("invalid API key")
	// ErrEmptyMessage indicates the message is empty.
	ErrEmptyMessage = errors.New("message is empty")
	// ErrInvalidURL indicates an invalid URL format.
	ErrInvalidURL = errors.New("invalid URL format")
	// ErrInvalidChannelID indicates an invalid channel ID.
	ErrInvalidChannelID = errors.New("invalid channel ID")
	// ErrNoDiscordFields indicates no Discord fields are present.
	ErrNoDiscordFields = errors.New("no Discord fields present")
)

// mentionRegex is a compiled regular expression for parsing Discord user/role mentions.
var mentionRegex = regexp.MustCompile(`<@!?(\d+)>|<@&(\d+)>`)

// Service implements a Notifiarr notification service.
type Service struct {
	standard.Standard
	Config *Config
	pkr    format.PropKeyResolver
}

// presenceFlags holds boolean flags indicating presence of Discord fields.
type presenceFlags struct {
	channel, color, thumbnail, image, title, icon, content, description, footer, fields, mentions bool
}

// HasAny returns true if any of the boolean fields are true, false otherwise.
func (pf presenceFlags) HasAny() bool {
	return pf.channel || pf.color || pf.thumbnail || pf.image || pf.title || pf.icon ||
		pf.content ||
		pf.description ||
		pf.footer ||
		pf.fields ||
		pf.mentions
}

// Send delivers a notification message to Notifiarr.
func (service *Service) Send(message string, paramsPtr *types.Params) error {
	// Check for empty message
	if message == "" {
		return ErrEmptyMessage
	}

	// Create a copy of the config to avoid modifying the original
	config := *service.Config

	var params types.Params
	// Handle nil params by creating empty map
	if paramsPtr == nil {
		params = types.Params{}
	} else {
		params = *paramsPtr
	}

	// Filter params to only include valid config keys for config updates
	validConfigKeys := map[string]bool{
		"name":      true,
		"channel":   true,
		"color":     true,
		"thumbnail": true,
		"image":     true,
	}
	filteredParams := types.Params{}

	for k, v := range params {
		if validConfigKeys[k] {
			filteredParams[k] = v
		}
	}

	// Update config with filtered parameters
	if err := service.pkr.UpdateConfigFromParams(&config, &filteredParams); err != nil {
		service.Logf("Failed to update params: %v", err)
	}

	// Create the payload
	payload, err := service.createPayload(message, params, &config)
	if err != nil {
		return fmt.Errorf("creating payload: %w", err)
	}

	// Send the notification
	if err := service.doSend(payload); err != nil {
		return fmt.Errorf("%w: %s", ErrSendFailed, err.Error())
	}

	return nil
}

// Initialize configures the service with a URL and logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
	// Set the logger for the service
	service.SetLogger(logger)
	// Initialize service config
	service.Config = &Config{}
	// Initialize property key resolver
	service.pkr = format.NewPropKeyResolver(service.Config)

	// Set default properties
	if err := service.pkr.SetDefaultProps(service.Config); err != nil {
		return fmt.Errorf("setting default properties: %w", err)
	}

	// Set URL and return any error
	if err := service.Config.SetURL(configURL); err != nil {
		return fmt.Errorf("setting config URL: %w", err)
	}

	return nil
}

// GetID returns the identifier for this service.
func (service *Service) GetID() string {
	return Scheme
}

// GetConfigURLFromCustom converts a custom webhook URL into a standard service URL.
func (*Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) {
	// Copy the URL to modify
	webhookURL := *customURL
	if strings.HasPrefix(webhookURL.Scheme, Scheme) && len(webhookURL.Scheme) > len(Scheme) &&
		webhookURL.Scheme[len(Scheme)] == '+' {
		// Remove the scheme prefix if present
		webhookURL.Scheme = webhookURL.Scheme[len(Scheme)+1:]
	}

	// Parse config from webhook URL
	config, pkr, err := ConfigFromWebhookURL(webhookURL)
	if err != nil {
		return nil, err
	}

	// Generate and return the service URL
	return config.getURL(&pkr), nil
}

// parseChannelID parses the channel string to an integer.
func (service *Service) parseChannelID(channelStr string) (int, error) {
	var channelID int

	_, err := fmt.Sscanf(channelStr, "%d", &channelID)
	if err != nil {
		return 0, fmt.Errorf("invalid channel ID format '%s': %w", channelStr, ErrInvalidChannelID)
	}

	return channelID, nil
}

// parseMentions extracts Discord user and role mentions from the message content.
func (service *Service) parseMentions(message string) []string {
	var mentions []string

	matches := mentionRegex.FindAllStringSubmatch(message, -1)
	for _, match := range matches {
		if match[1] != "" {
			mentions = append(mentions, fmt.Sprintf("<@%s>", match[1]))
		} else if match[2] != "" {
			mentions = append(mentions, fmt.Sprintf("<@&%s>", match[2]))
		}
	}

	return mentions
}

// parseFields parses a JSON string into a slice of Field structs.
func (service *Service) parseFields(fieldsStr string) ([]Field, error) {
	var fields []Field
	if err := json.Unmarshal([]byte(fieldsStr), &fields); err != nil {
		return nil, fmt.Errorf("unmarshaling fields JSON: %w", err)
	}

	return fields, nil
}

// parseMention parses a single mention string and returns the type (0=none, 1=user, 2=role) and ID if valid.
func parseMention(mention string) (int, int) {
	if !strings.HasPrefix(mention, "<@") || !strings.HasSuffix(mention, ">") {
		return mentionTypeNone, 0
	}

	idStr := mention[2 : len(mention)-1]
	if strings.HasPrefix(idStr, "&") {
		roleIDStr := idStr[1:]
		if roleID, err := strconv.Atoi(roleIDStr); err == nil {
			return mentionTypeRole, roleID
		}
	} else {
		if userID, err := strconv.Atoi(idStr); err == nil {
			return mentionTypeUser, userID
		}
	}

	return mentionTypeNone, 0
}

// extractPingIDs extracts user and role IDs from mention strings.
func (service *Service) extractPingIDs(mentions []string) (int, int) {
	var pingUser, pingRole int

	for _, mention := range mentions {
		mentionType, id := parseMention(mention)
		if mentionType == mentionTypeUser && pingUser == 0 {
			pingUser = id
		} else if mentionType == mentionTypeRole && pingRole == 0 {
			pingRole = id
		}
	}

	return pingUser, pingRole
}

// parseUpdateFlag parses the update parameter from params.
func parseUpdateFlag(params types.Params) *bool {
	if updateStr, exists := params["update"]; exists && updateStr != "" {
		switch updateStr {
		case "true":
			return &[]bool{true}[0]
		case "false":
			return &[]bool{false}[0]
		}
	}

	return nil
}

// buildNotificationData creates the notification data structure.
func buildNotificationData(updatePtr *bool, config *Config, params types.Params) NotificationData {
	return NotificationData{
		Update: updatePtr,
		Name:   config.Name,
		Event:  params["id"],
	}
}

// checkPresenceFlags determines which Discord fields are present.
func checkPresenceFlags(
	message string,
	params types.Params,
	config *Config,
	service *Service,
) presenceFlags {
	return presenceFlags{
		channel:     config.Channel != "",
		color:       config.Color != "",
		thumbnail:   config.Thumbnail != "",
		image:       config.Image != "",
		title:       params[types.TitleKey] != "",
		icon:        params["icon"] != "",
		content:     params["content"] != "",
		description: message != "",
		footer:      params["footer"] != "",
		fields:      params["fields"] != "",
		mentions:    len(service.parseMentions(message)) > 0,
	}
}

// buildDiscordPayload constructs the Discord payload if any fields are present.
func (service *Service) buildDiscordPayload(
	flags presenceFlags,
	message string,
	params types.Params,
	config *Config,
) (*DiscordPayload, error) {
	if !flags.HasAny() {
		return nil, ErrNoDiscordFields
	}

	discord := &DiscordPayload{}

	if flags.channel {
		// Parse channel ID from config string to integer
		channelID, err := service.parseChannelID(config.Channel)
		if err != nil {
			return nil, fmt.Errorf("parsing channel ID: %w", err)
		}

		discord.IDs = &IDPayload{Channel: channelID}
	}

	if flags.color {
		// Assign color from config
		discord.Color = config.Color
	}

	if flags.thumbnail || flags.image {
		// Set thumbnail and image URLs from config
		discord.Images = &ImagePayload{
			Thumbnail: config.Thumbnail,
			Image:     config.Image,
		}
	}

	// Construct text payload with title, icon, content, description, and footer from params
	textPayload := &TextPayload{
		Title:       params[types.TitleKey],
		Icon:        params["icon"],
		Content:     params["content"],
		Description: message,
		Footer:      params["footer"],
	}

	if flags.fields {
		// Parse JSON fields string into Field structs
		fields, err := service.parseFields(params["fields"])
		if err != nil {
			return nil, fmt.Errorf("parsing fields: %w", err)
		}

		textPayload.Fields = fields
	}

	discord.Text = textPayload

	if flags.mentions {
		// Extract Discord mentions from message content
		mentions := service.parseMentions(message)

		// Extract user and role IDs for ping setup
		pingUser, pingRole := service.extractPingIDs(mentions)
		if pingUser > 0 || pingRole > 0 {
			discord.Ping = &PingPayload{
				PingUser: pingUser,
				PingRole: pingRole,
			}
		}
	}

	return discord, nil
}

// createPayload creates the JSON payload for Notifiarr API.
func (service *Service) createPayload(
	message string,
	params types.Params,
	config *Config,
) ([]byte, error) {
	// Parse the update parameter from params
	updatePtr := parseUpdateFlag(params)
	// Build the notification data structure
	notificationData := buildNotificationData(updatePtr, config, params)
	// Check presence flags for Discord fields
	flags := checkPresenceFlags(message, params, config, service)

	// Build the Discord payload if fields are present
	discord, err := service.buildDiscordPayload(flags, message, params, config)
	if err != nil {
		return nil, err
	}

	notification := NotificationPayload{
		Notification: notificationData,
		Discord:      discord,
	}

	// Marshal the notification to JSON
	payloadBytes, err := json.Marshal(notification)
	if err != nil {
		return nil, fmt.Errorf("marshaling payload to JSON: %w", err)
	}

	return payloadBytes, nil
}

// doSend executes the HTTP request to send a notification to Notifiarr.
func (service *Service) doSend(payload []byte) error {
	// Build the API URL with API key
	apiURL := fmt.Sprintf("%s/%s", APIBaseURL, service.Config.APIKey)

	// Create background context for the request
	ctx := context.Background()

	// Create HTTP request with context
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(payload))
	if err != nil {
		return fmt.Errorf("creating HTTP request: %w", err)
	}

	// Set headers
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	// Send the HTTP request
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return fmt.Errorf("sending HTTP request: %w", err)
	}

	if res != nil && res.Body != nil {
		defer func() {
			_ = res.Body.Close()
		}()

		if body, err := io.ReadAll(res.Body); err == nil {
			service.Log("Server response: ", string(body))
		}
	}

	if res.StatusCode < 200 || res.StatusCode >= 300 {
		return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status)
	}

	return nil
}