switch to http client for pushover notifier

This commit is contained in:
CrazyMax
2025-08-31 15:11:33 +02:00
parent 5f67b0d8ea
commit cf2edc7db2
20 changed files with 105 additions and 1114 deletions

View File

@@ -12,6 +12,7 @@ You can send notifications using [Pushover](https://pushover.net/).
recipient: gznej3rKEVAvPUxu9vvNnqpmZpokzF
priority: -2
sound: none
timeout: 10s
templateTitle: "{{ .Entry.Image }} released"
templateBody: |
Docker tag {{ .Entry.Image }} which you subscribed to through {{ .Entry.Provider }} provider has been released.
@@ -25,6 +26,7 @@ You can send notifications using [Pushover](https://pushover.net/).
| `recipientFile` | | Use content of secret file as User key if `recipient` not defined |
| `priority` | | Priority of the notification |
| `sound` | | Notification sound to be used |
| `timeout` | `10s` | Timeout specifies a time limit for the request to be made |
| `templateTitle`[^1] | See [below](#default-templatetitle) | [Notification template](../faq.md#notification-template) for message title |
| `templateBody`[^1] | See [below](#default-templatebody) | [Notification template](../faq.md#notification-template) for message body |
@@ -35,6 +37,7 @@ You can send notifications using [Pushover](https://pushover.net/).
* `DIUN_NOTIF_PUSHOVER_RECIPIENTFILE`
* `DIUN_NOTIF_PUSHOVER_PRIORITY`
* `DIUN_NOTIF_PUSHOVER_SOUND`
* `DIUN_NOTIF_PUSHOVER_TIMEOUT`
* `DIUN_NOTIF_PUSHOVER_TEMPLATETITLE`
* `DIUN_NOTIF_PUSHOVER_TEMPLATEBODY`

1
go.mod
View File

@@ -21,7 +21,6 @@ require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df
github.com/go-playground/validator/v10 v10.27.0
github.com/gregdel/pushover v1.3.1
github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b // v1.7.2
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/matcornic/hermes/v2 v2.1.0

2
go.sum
View File

@@ -167,8 +167,6 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gregdel/pushover v1.3.1 h1:4bMLITOZ15+Zpi6qqoGqOPuVHCwSUvMCgVnN5Xhilfo=
github.com/gregdel/pushover v1.3.1/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/hashicorp/cronexpr v1.1.2 h1:wG/ZYIKT+RT3QkOdgYc+xsKWVRgnxJ1OJtjjy84fJ9A=

View File

@@ -160,6 +160,7 @@ for <code>{{ .Entry.Manifest.Platform }}</code> platform.
Pushover: &model.NotifPushover{
Token: "uQiRzpo4DXghDmr9QzzfQu27cmVRsG",
Recipient: "gznej3rKEVAvPUxu9vvNnqpmZpokzF",
Timeout: utl.NewDuration(10 * time.Second),
TemplateTitle: model.NotifDefaultTemplateTitle,
TemplateBody: model.NotifDefaultTemplateBody,
},

View File

@@ -1,5 +1,11 @@
package model
import (
"time"
"github.com/crazy-max/diun/v4/pkg/utl"
)
// NotifPushover holds Pushover notification configuration details
type NotifPushover struct {
Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"`
@@ -8,6 +14,7 @@ type NotifPushover struct {
RecipientFile string `yaml:"recipientFile,omitempty" json:"recipientFile,omitempty" validate:"omitempty,file"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
Sound string `yaml:"sound,omitempty" json:"sound,omitempty" validate:"omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
TemplateTitle string `yaml:"templateTitle,omitempty" json:"templateTitle,omitempty" validate:"required"`
TemplateBody string `yaml:"templateBody,omitempty" json:"templateBody,omitempty" validate:"required"`
}
@@ -21,6 +28,7 @@ func (s *NotifPushover) GetDefaults() *NotifPushover {
// SetDefaults sets the default values
func (s *NotifPushover) SetDefaults() {
s.Timeout = utl.NewDuration(10 * time.Second)
s.TemplateTitle = NotifDefaultTemplateTitle
s.TemplateBody = NotifDefaultTemplateBody
}

View File

@@ -1,16 +1,24 @@
package pushover
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/crazy-max/diun/v4/internal/model"
"github.com/crazy-max/diun/v4/internal/msg"
"github.com/crazy-max/diun/v4/internal/notif/notifier"
"github.com/crazy-max/diun/v4/pkg/utl"
"github.com/gregdel/pushover"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
const pushoverAPIURL = "https://api.pushover.net/1/messages.json"
// Client represents an active Pushover notification object
type Client struct {
*notifier.Notifier
@@ -38,11 +46,15 @@ func (c *Client) Send(entry model.NotifEntry) error {
token, err := utl.GetSecret(c.cfg.Token, c.cfg.TokenFile)
if err != nil {
return errors.Wrap(err, "cannot retrieve token secret for Pushover notifier")
} else if token == "" {
return errors.New("Pushover API token cannot be empty")
}
recipient, err := utl.GetSecret(c.cfg.Recipient, c.cfg.RecipientFile)
if err != nil {
return errors.Wrap(err, "cannot retrieve recipient secret for Pushover notifier")
} else if recipient == "" {
return errors.New("Pushover recipient cannot be empty")
}
message, err := msg.New(msg.Options{
@@ -60,16 +72,77 @@ func (c *Client) Send(entry model.NotifEntry) error {
return err
}
_, err = pushover.New(token).SendMessage(&pushover.Message{
Title: string(title),
Message: string(body),
Priority: c.cfg.Priority,
Sound: c.cfg.Sound,
URL: c.meta.URL,
URLTitle: c.meta.Name,
Timestamp: time.Now().Unix(),
HTML: true,
}, pushover.NewRecipient(recipient))
cancelCtx, cancel := context.WithCancelCause(context.Background())
timeoutCtx, _ := context.WithTimeoutCause(cancelCtx, *c.cfg.Timeout, errors.WithStack(context.DeadlineExceeded)) //nolint:govet // no need to manually cancel this context as we already rely on parent
defer func() { cancel(errors.WithStack(context.Canceled)) }()
form := url.Values{}
form.Add("token", token)
form.Add("user", recipient)
form.Add("title", string(title))
form.Add("message", string(body))
form.Add("priority", strconv.Itoa(c.cfg.Priority))
if c.cfg.Sound != "" {
form.Add("sound", c.cfg.Sound)
}
if c.meta.URL != "" {
form.Add("url", c.meta.URL)
}
if c.meta.Name != "" {
form.Add("url_title", c.meta.Name)
}
form.Add("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
form.Add("html", "1")
hc := http.Client{}
req, err := http.NewRequestWithContext(timeoutCtx, "POST", pushoverAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", c.meta.UserAgent)
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.Header != nil {
var appLimit, appRemaining int
var appReset time.Time
if limit := resp.Header.Get("X-Limit-App-Limit"); limit != "" {
if i, err := strconv.Atoi(limit); err == nil {
appLimit = i
}
}
if remaining := resp.Header.Get("X-Limit-App-Remaining"); remaining != "" {
if i, err := strconv.Atoi(remaining); err == nil {
appRemaining = i
}
}
if reset := resp.Header.Get("X-Limit-App-Reset"); reset != "" {
if i, err := strconv.Atoi(reset); err == nil {
appReset = time.Unix(int64(i), 0)
}
}
log.Debug().Msgf("Pushover app limit: %d, remaining: %d, reset: %s", appLimit, appRemaining, appReset)
}
var respBody struct {
Status int `json:"status"`
Request string `json:"request"`
Errors []string `json:"errors"`
User string `json:"user"`
Token string `json:"token"`
}
if err = json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return errors.Wrapf(err, "cannot decode JSON body response for HTTP %d %s status: %+v", resp.StatusCode, http.StatusText(resp.StatusCode), respBody)
}
if respBody.Status != 1 {
return errors.Errorf("Pushover API call failed with status %d: %v", respBody.Status, respBody.Errors)
}
return nil
}

View File

@@ -1,9 +0,0 @@
language: go
go:
- 1.15.2
before_install:
- go get github.com/axw/gocov/gocov
- go get github.com/mattn/goveralls
- if ! go get code.google.com/p/go.tools/cmd/cover; then go get golang.org/x/tools/cmd/cover; fi
script:
- $HOME/gopath/bin/goveralls -service=travis-ci

View File

@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) Grégoire Delattre <gregoire.delattre@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,134 +0,0 @@
pushover
=========
[![GoDoc](https://godoc.org/github.com/gregdel/pushover?status.svg)](http://godoc.org/github.com/gregdel/pushover)
[![Build Status](https://travis-ci.org/gregdel/pushover.svg?branch=master)](https://travis-ci.org/gregdel/pushover)
[![Coverage Status](https://coveralls.io/repos/gregdel/pushover/badge.svg?branch=master&service=github)](https://coveralls.io/github/gregdel/pushover?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/gregdel/pushover)](https://goreportcard.com/report/github.com/gregdel/pushover)
pushover is a wrapper around the Superblock's Pushover API written in go.
Based on their [documentation](https://pushover.net/api). It's a convenient way to send notifications from a go program with only a few lines of code.
## Messages
### Send a simple message
Here is a simple example for sending a notification to a recipient. A recipient can be a user or a group. There is no real difference, they both use a notification token.
```go
package main
import (
"log"
"github.com/gregdel/pushover"
)
func main() {
// Create a new pushover app with a token
app := pushover.New("uQiRzpo4DXghDmr9QzzfQu27cmVRsG")
// Create a new recipient
recipient := pushover.NewRecipient("gznej3rKEVAvPUxu9vvNnqpmZpokzF")
// Create the message to send
message := pushover.NewMessage("Hello !")
// Send the message to the recipient
response, err := app.SendMessage(message, recipient)
if err != nil {
log.Panic(err)
}
// Print the response if you want
log.Println(response)
}
```
### Send a message with a title
There is a simple way to create a message with a title. Instead of using pushover.NewMessage you can use pushover.NewMessageWithTitle.
```go
message := pushover.NewMessageWithTitle("My awesome message", "My title")
```
### Send a fancy message
If you want a more detailed message you can still do it.
```go
message := &pushover.Message{
Message: "My awesome message",
Title: "My title",
Priority: pushover.PriorityEmergency,
URL: "http://google.com",
URLTitle: "Google",
Timestamp: time.Now().Unix(),
Retry: 60 * time.Second,
Expire: time.Hour,
DeviceName: "SuperDevice",
CallbackURL: "http://yourapp.com/callback",
Sound: pushover.SoundCosmic,
}
```
### Send a message with an attachment
You can send an image attachment along with the message.
```go
file, err := os.Open("/some/image.png")
if err != nil {
panic(err)
}
defer file.Close()
message := pushover.NewMessage("Hello !")
if err := message.AddAttachment(file); err != nil {
panic(err)
}
```
## Callbacks and receipts
If you're using an emergency notification you'll have to specify a retry period and an expiration delay. You can get the receipt details using the token in the message response.
```go
...
response, err := app.SendMessage(message, recipient)
if err != nil {
log.Panic(err)
}
receiptDetails, err := app.GetReceiptDetails(response.Receipt)
if err != nil {
log.Panic(err)
}
fmt.Println("Acknowledged status :", receiptDetails.Acknowledged)
```
You can also cancel an emergency notification before the expiration time.
```go
response, err := app.CancelEmergencyNotification(response.Receipt)
if err != nil {
log.Panic(err)
}
```
## User verification
If you want to validate that the recipient token is valid.
```go
...
recipientDetails, err := app.GetRecipientDetails(recipient)
if err != nil {
log.Panic(err)
}
fmt.Println(recipientDetails)
```

View File

@@ -1,18 +0,0 @@
package pushover
import (
"strings"
)
// Errors represents the errors returned by pushover.
type Errors []string
// Error represents the error as a string.
func (e Errors) Error() string {
ret := ""
if len(e) > 0 {
ret = "Errors:\n"
ret += strings.Join(e, "\n")
}
return ret
}

View File

@@ -1,118 +0,0 @@
package pushover
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
)
const (
// GlancesAllDevices can be passed as a device name to send a glances-message to all devices
GlancesAllDevices = ""
// GlancesMessageMaxTitleLength is the max title length in a pushover glance update
GlancesMessageMaxTitleLength = 100
// GlancesMessageMaxTextLength is the max text length in a pushover glance update
GlancesMessageMaxTextLength = 100
// GlancesMessageMaxSubtextLength is the max subtext length in a pushover glance update
GlancesMessageMaxSubtextLength = 100
)
// Glance represents a pushover glances update request.
type Glance struct {
// Title(max 100): a description of the data being shown, such as "Widgets Sold"
Title *string
// Text(max 100): the main line of data, used on most screens
Text *string
// Subtext(max 100): a second line of data
Subtext *string
// Count(can be negative): shown on smaller screens; useful for simple counts
Count *int
// Percent(0-100): shown on some screens as a progress bar/circle
Percent *int
DeviceName string
}
// Int returns the pointer of the input i
// Used to check for the Glance API if a parameter was left empty or if none was specified
func Int(i int) *int {
return &i
}
// String returns the pointer of the input s
// Used to check for the Glance API if a parameter was left empty or if none was specified
func String(s string) *string {
return &s
}
func (m *Glance) validate() error {
// check if data is present
if m.Title == nil && m.Text == nil && m.Subtext == nil && m.Count == nil && m.Percent == nil {
return ErrGlancesMissingData
}
if m.Title != nil && utf8.RuneCountInString(*m.Title) > GlancesMessageMaxTitleLength {
return ErrGlancesTitleTooLong
}
if m.Text != nil && utf8.RuneCountInString(*m.Text) > GlancesMessageMaxTextLength {
return ErrGlancesTextTooLong
}
if m.Subtext != nil && utf8.RuneCountInString(*m.Subtext) > GlancesMessageMaxSubtextLength {
return ErrGlancesSubtextTooLong
}
if m.Percent != nil && (*m.Percent < 0 || *m.Percent > 100) {
return ErrGlancesInvalidPercent
}
// Test device name
if m.DeviceName != "" {
// Accept comma separated device names
devices := strings.Split(m.DeviceName, ",")
for _, d := range devices {
if !deviceNameRegexp.MatchString(d) {
return ErrInvalidDeviceName
}
}
}
return nil
}
// send sends the message using the pushover and the recipient tokens.
func (m *Glance) send(pToken, rToken string) (*Response, error) {
url := fmt.Sprintf("%s/glances.json", APIEndpoint)
params := map[string]string{
"token": pToken,
"user": rToken,
}
if m.DeviceName != "" {
params["device"] = m.DeviceName
}
// data
if m.Count != nil {
params["count"] = strconv.Itoa(*m.Count)
}
if m.Percent != nil {
params["percent"] = strconv.Itoa(*m.Percent)
}
if m.Title != nil {
params["title"] = *m.Title
}
if m.Text != nil {
params["text"] = *m.Text
}
if m.Subtext != nil {
params["subtext"] = *m.Subtext
}
req, err := newURLEncodedRequest("POST", url, params)
if err != nil {
return nil, err
}
resp := new(Response)
if err = do(req, resp, true); err != nil {
return nil, err
}
return resp, nil
}

View File

@@ -1,45 +0,0 @@
package pushover
import (
"encoding/json"
"fmt"
"time"
)
// Helper to unmarshal a timestamp as string to a time.Time.
type timestamp struct{ *time.Time }
func (t *timestamp) UnmarshalJSON(data []byte) error {
var i int64
if err := json.Unmarshal(data, &i); err != nil {
return err
}
if i > 0 {
unixTime := time.Unix(i, 0)
*t = timestamp{&unixTime}
}
return nil
}
// Helper to unmarshal a int as a boolean.
type intBool bool
func (i *intBool) UnmarshalJSON(data []byte) error {
var v int64
if err := json.Unmarshal(data, &v); err != nil {
return err
}
switch v {
case 0:
*i = false
case 1:
*i = true
default:
return fmt.Errorf("failed to unmarshal int to bool")
}
return nil
}

View File

@@ -1,57 +0,0 @@
package pushover
import (
"net/http"
"strconv"
"time"
)
// Limit represents the limitation of the application. This information is
// fetched when posting a new message.
// Headers example:
// X-Limit-App-Limit: 7500
// X-Limit-App-Remaining: 7496
// X-Limit-App-Reset: 1393653600
type Limit struct {
// Total number of messages you can send during a month.
Total int
// Remaining number of messages you can send until the next reset.
Remaining int
// NextReset is the time when all the app counters will be reseted.
NextReset time.Time
}
func newLimit(headers http.Header) (*Limit, error) {
headersStrings := []string{
"X-Limit-App-Limit",
"X-Limit-App-Remaining",
"X-Limit-App-Reset",
}
headersValues := map[string]int{}
for _, header := range headersStrings {
// Check if the header is present
h, ok := headers[header]
if !ok {
return nil, ErrInvalidHeaders
}
// The header must have only one element
if len(h) != 1 {
return nil, ErrInvalidHeaders
}
i, err := strconv.Atoi(h[0])
if err != nil {
return nil, err
}
headersValues[header] = i
}
return &Limit{
Total: headersValues["X-Limit-App-Limit"],
Remaining: headersValues["X-Limit-App-Remaining"],
NextReset: time.Unix(int64(headersValues["X-Limit-App-Reset"]), 0),
}, nil
}

View File

@@ -1,253 +0,0 @@
package pushover
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
var deviceNameRegexp *regexp.Regexp
func init() {
deviceNameRegexp = regexp.MustCompile(`^[A-Za-z0-9_-]{1,25}$`)
}
// Message represents a pushover message.
type Message struct {
// Required
Message string
// Optional
Title string
Priority int
URL string
URLTitle string
Timestamp int64
Retry time.Duration
Expire time.Duration
CallbackURL string
DeviceName string
Sound string
HTML bool
Monospace bool
TTL time.Duration
// attachment
attachment io.Reader
}
// NewMessage returns a simple new message.
func NewMessage(message string) *Message {
return &Message{Message: message}
}
// NewMessageWithTitle returns a simple new message with a title.
func NewMessageWithTitle(message, title string) *Message {
return &Message{Message: message, Title: title}
}
// AddAttachment adds an attachment to the message it's programmer's
// responsibility to close the reader.
func (m *Message) AddAttachment(attachment io.Reader) error {
m.attachment = attachment
return nil
}
// Validate the message values.
func (m *Message) validate() error {
// Message should no be empty
if m.Message == "" {
return ErrMessageEmpty
}
// Validate message length
if utf8.RuneCountInString(m.Message) > MessageMaxLength {
return ErrMessageTooLong
}
// Validate Title field length
if utf8.RuneCountInString(m.Title) > MessageTitleMaxLength {
return ErrMessageTitleTooLong
}
// Validate URL field
if utf8.RuneCountInString(m.URL) > MessageURLMaxLength {
return ErrMessageURLTooLong
}
// Validate URL title field
if utf8.RuneCountInString(m.URLTitle) > MessageURLTitleMaxLength {
return ErrMessageURLTitleTooLong
}
// URLTitle should not be set with an empty URL
if m.URL == "" && m.URLTitle != "" {
return ErrEmptyURL
}
// Validate priorities
if m.Priority > PriorityEmergency || m.Priority < PriorityLowest {
return ErrInvalidPriority
}
// Validate emergency priority
if m.Priority == PriorityEmergency {
if m.Retry == 0 || m.Expire == 0 {
return ErrMissingEmergencyParameter
}
}
// Test device name
if m.DeviceName != "" {
// Accept comma separated device names
devices := strings.Split(m.DeviceName, ",")
for _, d := range devices {
if !deviceNameRegexp.MatchString(d) {
return ErrInvalidDeviceName
}
}
}
return nil
}
// Return a map filled with the relevant data.
func (m *Message) toMap(pToken, rToken string) map[string]string {
ret := map[string]string{
"token": pToken,
"user": rToken,
"message": m.Message,
"priority": strconv.Itoa(m.Priority),
}
if m.Title != "" {
ret["title"] = m.Title
}
if m.URL != "" {
ret["url"] = m.URL
}
if m.URLTitle != "" {
ret["url_title"] = m.URLTitle
}
if m.Sound != "" {
ret["sound"] = m.Sound
}
if m.DeviceName != "" {
ret["device"] = m.DeviceName
}
if m.Timestamp != 0 {
ret["timestamp"] = strconv.FormatInt(m.Timestamp, 10)
}
if m.HTML {
ret["html"] = "1"
}
if m.Monospace {
ret["monospace"] = "1"
}
if m.Priority == PriorityEmergency {
ret["retry"] = strconv.FormatFloat(m.Retry.Seconds(), 'f', -1, 64)
ret["expire"] = strconv.FormatFloat(m.Expire.Seconds(), 'f', -1, 64)
if m.CallbackURL != "" {
ret["callback"] = m.CallbackURL
}
}
if m.TTL != 0 {
ret["ttl"] = strconv.FormatFloat(m.TTL.Seconds(), 'f', -1, 64)
}
return ret
}
// Send sends the message using the pushover and the recipient tokens.
func (m *Message) send(pToken, rToken string) (*Response, error) {
url := fmt.Sprintf("%s/messages.json", APIEndpoint)
var f func(string, string, string) (*http.Request, error)
if m.attachment == nil {
// Use a URL-encoded request if there's no need to attach files
f = m.urlEncodedRequest
} else {
// Use a multipart request if a file should be sent
f = m.multipartRequest
}
// Post the from and check the headers of the response
req, err := f(pToken, rToken, url)
if err != nil {
return nil, err
}
resp := &Response{}
if err := do(req, resp, true); err != nil {
return nil, err
}
return resp, nil
}
// multipartRequest returns a new multipart POST request with a file attached.
func (m *Message) multipartRequest(pToken, rToken, url string) (*http.Request, error) {
body := &bytes.Buffer{}
if m.attachment == nil {
return nil, ErrMissingAttachment
}
// Write the body as multipart form data
w := multipart.NewWriter(body)
// Write the file in the body
fw, err := w.CreateFormFile("attachment", "attachment")
if err != nil {
return nil, err
}
written, err := io.Copy(fw, m.attachment)
if err != nil {
return nil, err
}
if written > MessageMaxAttachmentByte {
return nil, ErrMessageAttachmentTooLarge
}
// Handle params
for k, v := range m.toMap(pToken, rToken) {
if err := w.WriteField(k, v); err != nil {
return nil, err
}
}
if err := w.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", w.FormDataContentType())
return req, nil
}
// urlEncodedRequest returns a new url encoded request.
func (m *Message) urlEncodedRequest(pToken, rToken, endpoint string) (*http.Request, error) {
return newURLEncodedRequest("POST", endpoint, m.toMap(pToken, rToken))
}

View File

@@ -1,236 +0,0 @@
// Package pushover provides a wrapper around the Pushover API
package pushover
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
)
// Regexp validation.
var tokenRegexp *regexp.Regexp
func init() {
tokenRegexp = regexp.MustCompile(`^[A-Za-z0-9]{30}$`)
}
// APIEndpoint is the API base URL for any request.
var APIEndpoint = "https://api.pushover.net/1"
// Pushover custom errors.
var (
ErrHTTPPushover = errors.New("pushover: http error")
ErrEmptyToken = errors.New("pushover: empty API token")
ErrEmptyURL = errors.New("pushover: empty URL, URLTitle needs an URL")
ErrEmptyRecipientToken = errors.New("pushover: empty recipient token")
ErrInvalidRecipientToken = errors.New("pushover: invalid recipient token")
ErrInvalidHeaders = errors.New("pushover: invalid headers in server response")
ErrInvalidPriority = errors.New("pushover: invalid priority")
ErrInvalidToken = errors.New("pushover: invalid API token")
ErrMessageEmpty = errors.New("pushover: message empty")
ErrMessageTitleTooLong = errors.New("pushover: message title too long")
ErrMessageTooLong = errors.New("pushover: message too long")
ErrMessageAttachmentTooLarge = errors.New("pushover: message attachment is too large")
ErrMessageURLTitleTooLong = errors.New("pushover: message URL title too long")
ErrMessageURLTooLong = errors.New("pushover: message URL too long")
ErrMissingAttachment = errors.New("pushover: missing attachment")
ErrMissingEmergencyParameter = errors.New("pushover: missing emergency parameter")
ErrInvalidDeviceName = errors.New("pushover: invalid device name")
ErrEmptyReceipt = errors.New("pushover: empty receipt")
ErrGlancesMissingData = errors.New("pushover: glance update data missing")
ErrGlancesTitleTooLong = errors.New("pushover: glance title too long")
ErrGlancesTextTooLong = errors.New("pushover: glance text too long")
ErrGlancesSubtextTooLong = errors.New("pushover: glance subtext too long")
ErrGlancesInvalidPercent = errors.New("pushover: glance percent must be in range of 0-100")
)
// API limitations.
const (
// MessageMaxLength is the max message number of characters.
MessageMaxLength = 1024
// MessageTitleMaxLength is the max title number of characters.
MessageTitleMaxLength = 250
// MessageURLMaxLength is the max URL number of characters.
MessageURLMaxLength = 512
// MessageURLTitleMaxLength is the max URL title number of characters.
MessageURLTitleMaxLength = 100
// MessageMaxAttachmentByte is the max attachment size in byte.
MessageMaxAttachmentByte = 2621440
)
// Message priorities
const (
PriorityLowest = -2
PriorityLow = -1
PriorityNormal = 0
PriorityHigh = 1
PriorityEmergency = 2
)
// Sounds
const (
SoundPushover = "pushover"
SoundBike = "bike"
SoundBugle = "bugle"
SoundCashRegister = "cashregister"
SoundClassical = "classical"
SoundCosmic = "cosmic"
SoundFalling = "falling"
SoundGamelan = "gamelan"
SoundIncoming = "incoming"
SoundIntermission = "intermission"
SoundMagic = "magic"
SoundMechanical = "mechanical"
SoundPianobar = "pianobar"
SoundSiren = "siren"
SoundSpaceAlarm = "spacealarm"
SoundTugBoat = "tugboat"
SoundAlien = "alien"
SoundClimb = "climb"
SoundPersistent = "persistent"
SoundEcho = "echo"
SoundUpDown = "updown"
SoundVibrate = "vibrate"
SoundNone = "none"
)
// Pushover is the representation of an app using the pushover API.
type Pushover struct {
token string
}
// New returns a new app to talk to the pushover API.
func New(token string) *Pushover {
return &Pushover{token}
}
// Validate Pushover token.
func (p *Pushover) validate() error {
// Check empty token
if p.token == "" {
return ErrEmptyToken
}
// Check invalid token
if !tokenRegexp.MatchString(p.token) {
return ErrInvalidToken
}
return nil
}
// SendMessage is used to send message to a recipient.
func (p *Pushover) SendMessage(message *Message, recipient *Recipient) (*Response, error) {
// Validate pushover
if err := p.validate(); err != nil {
return nil, err
}
// Validate recipient
if err := recipient.validate(); err != nil {
return nil, err
}
// Validate message
if err := message.validate(); err != nil {
return nil, err
}
return message.send(p.token, recipient.token)
}
// SendGlanceUpdate is used to send glance updates to a recipient.
// It can be used to display widgets on a smart watch
func (p *Pushover) SendGlanceUpdate(msg *Glance, rec *Recipient) (*Response, error) {
// Validate pushover
if err := p.validate(); err != nil {
return nil, err
}
// Validate rec
if err := rec.validate(); err != nil {
return nil, err
}
// Validate msg
if err := msg.validate(); err != nil {
return nil, err
}
return msg.send(p.token, rec.token)
}
// GetReceiptDetails return detailed information about a receipt. This is used
// used to check the acknowledged status of an Emergency notification.
func (p *Pushover) GetReceiptDetails(receipt string) (*ReceiptDetails, error) {
url := fmt.Sprintf("%s/receipts/%s.json?token=%s", APIEndpoint, receipt, p.token)
if receipt == "" {
return nil, ErrEmptyReceipt
}
// Send request
resp, err := http.Get(url)
if err != nil {
return nil, err
}
// Decode the JSON response
var details *ReceiptDetails
if err = json.NewDecoder(resp.Body).Decode(&details); err != nil {
return nil, err
}
return details, nil
}
// GetRecipientDetails allows to check if a recipient exists, if it's a group
// and the devices associated to this recipient. The Errors field of the
// RecipientDetails object will contain an error if the recipient is not valid
// in the Pushover API.
func (p *Pushover) GetRecipientDetails(recipient *Recipient) (*RecipientDetails, error) {
endpoint := fmt.Sprintf("%s/users/validate.json", APIEndpoint)
// Validate pushover
if err := p.validate(); err != nil {
return nil, err
}
// Validate recipient
if err := recipient.validate(); err != nil {
return nil, err
}
req, err := newURLEncodedRequest("POST", endpoint,
map[string]string{"token": p.token, "user": recipient.token})
if err != nil {
return nil, err
}
var response RecipientDetails
if err := do(req, &response, false); err != nil {
return nil, err
}
return &response, nil
}
// CancelEmergencyNotification helps stop a notification retry in case of a
// notification with an Emergency priority before reaching the expiration time.
// It requires the response receipt in order to stop the right notification.
func (p *Pushover) CancelEmergencyNotification(receipt string) (*Response, error) {
endpoint := fmt.Sprintf("%s/receipts/%s/cancel.json", APIEndpoint, receipt)
req, err := newURLEncodedRequest("POST", endpoint, map[string]string{"token": p.token})
if err != nil {
return nil, err
}
response := &Response{}
if err := do(req, response, false); err != nil {
return nil, err
}
return response, nil
}

View File

@@ -1,59 +0,0 @@
package pushover
import (
"bytes"
"encoding/json"
"time"
)
// ReceiptDetails represents the receipt informations in case of emergency
// priority.
type ReceiptDetails struct {
Status int
Acknowledged bool
AcknowledgedBy string
Expired bool
CalledBack bool
ID string
AcknowledgedAt *time.Time
LastDeliveredAt *time.Time
ExpiresAt *time.Time
CalledBackAt *time.Time
}
// UnmarshalJSON is a custom unmarshal function to handle timestamps and
// boolean as int and convert them to the right type.
func (r *ReceiptDetails) UnmarshalJSON(data []byte) error {
dataBytes := bytes.NewReader(data)
var aux struct {
ID string `json:"request"`
Status int `json:"status"`
Acknowledged intBool `json:"acknowledged"`
AcknowledgedBy string `json:"acknowledged_by"`
Expired intBool `json:"expired"`
CalledBack intBool `json:"called_back"`
AcknowledgedAt *timestamp `json:"acknowledged_at"`
LastDeliveredAt *timestamp `json:"last_delivered_at"`
ExpiresAt *timestamp `json:"expires_at"`
CalledBackAt *timestamp `json:"called_back_at"`
}
// Decode json into the aux struct
if err := json.NewDecoder(dataBytes).Decode(&aux); err != nil {
return err
}
// Set the RecipientDetails with the right types
r.Status = aux.Status
r.Acknowledged = bool(aux.Acknowledged)
r.AcknowledgedBy = aux.AcknowledgedBy
r.Expired = bool(aux.Expired)
r.CalledBack = bool(aux.CalledBack)
r.ID = aux.ID
r.AcknowledgedAt = aux.AcknowledgedAt.Time
r.LastDeliveredAt = aux.LastDeliveredAt.Time
r.ExpiresAt = aux.ExpiresAt.Time
r.CalledBackAt = aux.CalledBackAt.Time
return nil
}

View File

@@ -1,43 +0,0 @@
package pushover
import "regexp"
var recipientRegexp *regexp.Regexp
func init() {
recipientRegexp = regexp.MustCompile(`^[A-Za-z0-9]{30}$`)
}
// Recipient represents the a recipient to notify.
type Recipient struct {
token string
}
// NewRecipient is the representation of the recipient to notify.
func NewRecipient(token string) *Recipient {
return &Recipient{token}
}
// Validates recipient token.
func (r *Recipient) validate() error {
// Check empty token
if r.token == "" {
return ErrEmptyRecipientToken
}
// Check invalid token
if !recipientRegexp.MatchString(r.token) {
return ErrInvalidRecipientToken
}
return nil
}
// RecipientDetails represents the receipt informations in case of emergency
// priority.
type RecipientDetails struct {
Status int `json:"status"`
Group int `json:"group"`
Devices []string `json:"devices"`
RequestID string `json:"request"`
Errors Errors `json:"errors"`
}

View File

@@ -1,69 +0,0 @@
package pushover
import (
"encoding/json"
"net/http"
"net/url"
"strings"
)
// do is a generic function to send a request to the API.
func do(req *http.Request, resType interface{}, returnHeaders bool) error {
client := http.DefaultClient
// Send request
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Only 500 errors will not respond a readable result
if resp.StatusCode >= http.StatusInternalServerError {
return ErrHTTPPushover
}
// Decode the JSON response
if err := json.NewDecoder(resp.Body).Decode(&resType); err != nil {
return err
}
// Check if the unmarshaled data is a response
r, ok := resType.(*Response)
if !ok {
return nil
}
// Check response status
if r.Status != 1 {
return r.Errors
}
// The headers are only returned when posting a new notification
if returnHeaders {
// Get app limits from headers
appLimits, err := newLimit(resp.Header)
if err != nil {
return err
}
r.Limit = appLimits
}
return nil
}
// urlEncodedRequest returns a new url encoded request.
func newURLEncodedRequest(method, endpoint string, params map[string]string) (*http.Request, error) {
urlValues := url.Values{}
for k, v := range params {
urlValues.Add(k, v)
}
req, err := http.NewRequest(method, endpoint, strings.NewReader(urlValues.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req, nil
}

View File

@@ -1,26 +0,0 @@
package pushover
import "fmt"
// Response represents a response from the API.
type Response struct {
Status int `json:"status"`
ID string `json:"request"`
Errors Errors `json:"errors"`
Receipt string `json:"receipt"`
Limit *Limit
}
// String represents a printable form of the response.
func (r Response) String() string {
ret := fmt.Sprintf("Status: %d\n", r.Status)
ret += fmt.Sprintf("Request id: %s\n", r.ID)
if r.Receipt != "" {
ret += fmt.Sprintf("Receipt: %s\n", r.Receipt)
}
if r.Limit != nil {
ret += fmt.Sprintf("Usage %d/%d messages\nNext reset : %s",
r.Limit.Remaining, r.Limit.Total, r.Limit.NextReset)
}
return ret
}

3
vendor/modules.txt vendored
View File

@@ -278,9 +278,6 @@ github.com/gorilla/mux
# github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
## explicit; go 1.20
github.com/gorilla/websocket
# github.com/gregdel/pushover v1.3.1
## explicit; go 1.14
github.com/gregdel/pushover
# github.com/hashicorp/cronexpr v1.1.2
## explicit
github.com/hashicorp/cronexpr