~bayindirh/nudge

c6cd0e83e3fc1176a0a4c906864d54c93539b0ea — Hakan Bayindir 4 months ago 197fd00
feat: Add message TTL support and more.

- feat: Do not send a sound parameter unless user overrides it, since it's required by the API.
- proj: Bump project version to 0.4.0a2.
- feat: Warn user if -url_title is used without -url flag.
- feat: Do not send -url_title if no `-url` is specified.
- refactor: Rewrite notification sending function to handle optional fields correctly.
- feat: Add message time to live support.
2 files changed, 96 insertions(+), 28 deletions(-)

M CHANGELOG.md
M src/nudge.go
M CHANGELOG.md => CHANGELOG.md +7 -1
@@ 4,7 4,13 @@

## 2023-10-19

- proj: Bump project version to 0.4.0a
- feat: Do not send a `sound` parameter unless user overrides it, since it's required by the API.
- proj: Bump project version to 0.4.0a2.
- feat: Warn user if `-url_title` is used without `-url` flag.
- feat: Do not send `-url_title` if no `-url` is specified.
- refactor: Rewrite notification sending function to handle optional fields correctly.
- feat: Add message time to live support.
- proj: Bump project version to 0.4.0a1
- feat: Add notification priority sanity checking support.
- fix: Call defaults from the correct structure (`runTimeConfiguration`) in `initFlags` function.
- fix: Store user supplied configuration file path in `runTimeConfiguration`.

M src/nudge.go => src/nudge.go +89 -27
@@ 44,6 44,7 @@ type Notification struct {
	priority        int    // Can be set from -2 to 2. 0 is default. Higher numbers are more urgent. Negative ones are informational levels.
	sound           string // Notification's sound. See the API docs for valid names.
	timestamp       int    // This is a UNIX timestamp.
	timeToLive      int    // Seconds before the notification is automatically deleted from recipient devices.
	title           string // Notification's title.
	url             string // You can attach a URL to a notification.
	urlTitle        string // You can add a title to the URL, if left blank, the URL will be shown.


@@ 65,12 66,13 @@ type RuntimeConfiguration struct {
// without altering the initialization order of the application.
type FlagStorage struct {
	// Below block contains variables about the notification itself.
	device   string // Contains a comma delimited list of devices which will receive the message.
	priority int    // Can be set from -2 to 2. 0 is default. Higher numbers are more urgent. Negative ones are informational levels.
	sound    string // Notification's sound. See the API docs for valid names.
	title    string // Notification's title.
	url      string // You can attach a URL to a notification.
	urlTitle string // You can add a title to the URL, if left blank, the URL will be shown.
	device     string // Contains a comma delimited list of devices which will receive the message.
	priority   int    // Can be set from -2 to 2. 0 is default. Higher numbers are more urgent. Negative ones are informational levels.
	sound      string // Notification's sound. See the API docs for valid names.
	timeToLive int    // Seconds before the notification is automatically deleted from recipient devices.
	title      string // Notification's title.
	url        string // You can attach a URL to a notification.
	urlTitle   string // You can add a title to the URL, if left blank, the URL will be shown.

	// Configuration file related flags are stored below:
	configFilePath string // Store the configuration file override here, if present.


@@ 84,8 86,9 @@ type FlagStorage struct {
// Following struct contains the set of acceptable configuration option values.
// checkConfigurationSanity() function consulsts this structure while verifying the final configuration.
type ValidConfigurationOptions struct {
	possibleLogLevels []string
	possibleLogLevels          []string
	possiblePriorityLevelRange []int
	minimumTimeToLive          int
}

// This function initializes the ValidConfigurationOptions data structure, which will be


@@ 94,6 97,7 @@ type ValidConfigurationOptions struct {
func initializeValidConfigurationOptions(ValidConfigurationOptions *ValidConfigurationOptions) {
	ValidConfigurationOptions.possibleLogLevels = []string{"debug", "info", "warn", "error", "fatal", "panic"}
	ValidConfigurationOptions.possiblePriorityLevelRange = []int{-2, 2}
	ValidConfigurationOptions.minimumTimeToLive = 0 // We'll set this to 0, so timeToLive should be zero or positive.
}

// This function stores and applies the defaults of the application.


@@ 113,14 117,14 @@ func applyDefaultConfiguration(runtimeConfiguration *RuntimeConfiguration, notif
	notificationToSend.device = "all"
	notificationToSend.html = 0
	notificationToSend.priority = 0
	notificationToSend.sound = "pushover"
	notificationToSend.sound = ""
	notificationToSend.title = notificationTitle

	// Let's set defaults for the application itself, again where it makes sense.
	// Some applications do not sanitize the name like that, but I prefer to do.
	applicationName := strings.Split(os.Args[0], "/")
	runtimeConfiguration.applicationName = applicationName[len(applicationName)-1]
	runtimeConfiguration.version = "0.4.0a1"
	runtimeConfiguration.version = "0.4.0a2"
	runtimeConfiguration.dryrun = false
	runtimeConfiguration.logLevel = "warn"
}


@@ 201,6 205,7 @@ func initFlags(notificationToSend *Notification, runtimeConfiguration *RuntimeCo
	flag.StringVar(&flagStorage.device, "devices", notificationToSend.device, "List of devices to be notified. Separate multiple devices with ','.")
	flag.IntVar(&flagStorage.priority, "priority", notificationToSend.priority, "Adjust notification priority. Between -2 and 2. (default "+strconv.Itoa(notificationToSend.priority)+")")
	flag.StringVar(&flagStorage.sound, "sound", notificationToSend.sound, "Set notification sound.")
	flag.IntVar(&flagStorage.timeToLive, "ttl", notificationToSend.timeToLive, "Seconds before the notification removed from devices automatically (ignored if priority is 2).")
	flag.StringVar(&flagStorage.title, "title", notificationToSend.title, "Notification title. Hostname is used if omitted.")
	flag.StringVar(&flagStorage.url, "url", notificationToSend.url, "An optional URL to attach to the notification.")
	flag.StringVar(&flagStorage.urlTitle, "url_title", notificationToSend.urlTitle, "An optional title for URL to send.")


@@ 244,7 249,7 @@ func applyFlags(setFlags *[]string, flagStorage *FlagStorage, runtimeConfigurati
	// It's fast enough, so I'm fine with this.
	for _, flagValue := range *setFlags {
		logger.Debugf("Working on flag %s.", flagValue)
		

		// The for loop above visits the set flags one by one, then we handle it here by looking up what it is.
		// Go doesn't provide JMP tables for switches, but I don't think this slows down that much.
		// ref: https://github.com/golang/go/issues/5496


@@ 258,6 263,9 @@ func applyFlags(setFlags *[]string, flagStorage *FlagStorage, runtimeConfigurati
		case "sound":
			notificationToSend.sound = flagStorage.sound
			logger.Debugf("Notification sound is changed to %s.", notificationToSend.sound)
		case "ttl":
			notificationToSend.timeToLive = flagStorage.timeToLive
			logger.Debugf("Notification's time to live is set to %s second(s).", notificationToSend.timeToLive)
		case "title":
			notificationToSend.title = flagStorage.title
			logger.Debugf("Notification title is changed to %s.", notificationToSend.title)


@@ 298,20 306,36 @@ func checkConfigurationSanity(notificationToSend *Notification, runtimeConfigura
	logLevelValid := !(slices.Index(validConfigurationOptions.possibleLogLevels, runtimeConfiguration.logLevel) == -1)

	if !logLevelValid {
		logger.Fatalf("Provided log level (%s) is not in the set of acceptable values. Please make sure you define or supply a valid log level.", runtimeConfiguration.logLevel)
		logger.Warnf("Provided log level (%s) is not in the set of acceptable values. Please make sure you define or supply a valid log level.", runtimeConfiguration.logLevel)
	}

	logger.Debugf("Provided log level (%s) is valid.", runtimeConfiguration.logLevel)
	

	// Check message notification is at acceptable levels and clamp the value if necessary.
	if notificationToSend.priority < validConfigurationOptions.possiblePriorityLevelRange[0] {
		logger.Warnf("Supplied notification priority %d is too low. Clamping to %d.", notificationToSend.priority, validConfigurationOptions.possiblePriorityLevelRange[0])
		notificationToSend.priority = validConfigurationOptions.possiblePriorityLevelRange[0]
	}else if notificationToSend.priority > validConfigurationOptions.possiblePriorityLevelRange[1] {
	} else if notificationToSend.priority > validConfigurationOptions.possiblePriorityLevelRange[1] {
		logger.Warnf("Supplied notification priority %d is too high. Clamping to %d.", notificationToSend.priority, validConfigurationOptions.possiblePriorityLevelRange[1])
		notificationToSend.priority = validConfigurationOptions.possiblePriorityLevelRange[1]
	}

	// Warn the user if both TTL is set and message priority is set to 2.
	if notificationToSend.timeToLive > validConfigurationOptions.minimumTimeToLive && notificationToSend.priority == validConfigurationOptions.possiblePriorityLevelRange[1] {
		logger.Warnf("Message is set to maximum priority (%d). Provided TTL of %d second(s) will be ignored by the API.", notificationToSend.priority, notificationToSend.timeToLive)
	}

	// Sending URL titles without URL is not meaningful. Inform user.
	if len(notificationToSend.url) == 0 && len(notificationToSend.urlTitle) > 0 {
		logger.Warnf("URL Title (-url_title) provided without an URL. URL Title will be ignored and will not be send.")
	}
	
	// TTL must be positive, and if it's negative, change it to 0, making message non-expiring.
	if notificationToSend.timeToLive < 0 {
		logger.Warnf("Message TTL cannot be negative. Removing TTL from the notification.")
		notificationToSend.timeToLive = 0
	}

	// Check whether we have the API & User keys.
	// Let's do some magic:
	apiKeyPresent := len(runtimeConfiguration.apiKey) != 0   // This evaluates to bool, heh. :)


@@ 348,8 372,22 @@ func printState(runtimeConfiguration *RuntimeConfiguration, notification *Notifi
	logger.Infof("--------------------")
	logger.Infof("Recipients: %s", notification.device)
	logger.Infof("Priority: %d", notification.priority)
	logger.Infof("Sound: %s", notification.sound)

	// If we don't override the sound, let it be known.
	if len(notification.sound) == 0 {
		logger.Infof("Sound: Recipient's default tone")
	} else {
		logger.Infof("Sound: %s", notification.sound)
	}
	logger.Infof("Timestamp: %d", notification.timestamp)

	// We default TTL to 0, and don't send a TTL to API if it's 0.
	if notification.timeToLive == 0 {
		logger.Infof("Message TTL: Infinite")
	} else {
		logger.Infof("Message TTL: %d second(s)", notification.timeToLive)
	}

	logger.Infof("HTML message: %d", notification.html)
	logger.Infof("URL title: %s", notification.urlTitle)
	logger.Infof("URL: %s", notification.url)


@@ 363,18 401,41 @@ func printState(runtimeConfiguration *RuntimeConfiguration, notification *Notifi
// This function sends the actual notification via PUSH request, via Pushover.
// TODO: Add logging directives into this function.
func sendNotification(notificationToSend *Notification, runtimeConfiguration *RuntimeConfiguration, logger *zap.SugaredLogger) (body []byte) {
	response, err := http.PostForm("https://api.pushover.net/1/messages.json",
		url.Values{
			"token":     {runtimeConfiguration.apiKey},
			"user":      {runtimeConfiguration.userKey},
			"device":    {notificationToSend.device},
			"sound":     {notificationToSend.sound},
			"priority":  {fmt.Sprint(notificationToSend.priority)},
			"url":       {notificationToSend.url},
			"url_title": {notificationToSend.urlTitle},
			"title":     {notificationToSend.title},
			"message":   {notificationToSend.messageBody},
			"html":      {fmt.Sprint(notificationToSend.html)}})

	valuesToSend := url.Values{} // We will build our URL object and send it to the API.

	// First the mandatory ones.
	valuesToSend.Add("token", runtimeConfiguration.apiKey)
	valuesToSend.Add("user", runtimeConfiguration.userKey)
	valuesToSend.Add("message", notificationToSend.messageBody)

	// These are declared by optipnal by API, but we always send them.
	valuesToSend.Add("device", notificationToSend.device)
	valuesToSend.Add("priority", fmt.Sprint(notificationToSend.priority))
	valuesToSend.Add("title", notificationToSend.title)
	valuesToSend.Add("html", fmt.Sprint(notificationToSend.html))

	// These are conditional variables, and we send them if they are set by the user.
	if len(notificationToSend.sound) > 0 {
		valuesToSend.Add("sound", notificationToSend.sound)
	}

	// Do not send a time to live value if we don't need to.
	if notificationToSend.timeToLive > 0 {
		valuesToSend.Add("ttl", fmt.Sprint(notificationToSend.timeToLive))
	}

	// Do not send a URL if there's none specified.
	if len(notificationToSend.url) > 0 {
		valuesToSend.Add("url", notificationToSend.url)
	}

	// Only send a URL title, if there's an URL to attach it.
	if len(notificationToSend.url) > 0 && len(notificationToSend.urlTitle) > 0 {
		valuesToSend.Add("url_title", notificationToSend.urlTitle)
	}

	response, err := http.PostForm("https://api.pushover.net/1/messages.json", valuesToSend)

	if err != nil {
		logger.Panicf("Something went wrong, exiting (error is %s).", err)


@@ 502,7 563,8 @@ func main() {

	// Let's send the notification:
	if runtimeConfiguration.dryrun == false {
		sendNotification(&notificationToSend, &runtimeConfiguration, sugaredLogger)
		result := sendNotification(&notificationToSend, &runtimeConfiguration, sugaredLogger)
		sugaredLogger.Debugf("Message sending result is %s.", result)
	} else {
		sugaredLogger.Warnf("Not sending the notification since in dry run mode.")
	}