2 Commits

Author SHA1 Message Date
spsobole 884f1d7b66 Add uniform scheme for kvs 2026-05-11 13:17:38 -06:00
spsobole 9bec5e0164 a bit of notifier reworking 2026-04-15 10:28:17 -06:00
+1 -1
View File
@@ -5,7 +5,7 @@ var DefaultTimeFormat = "2006-01-02 15:04:05"
var Default = &Log{
component: "ALL",
fmtTime: DefaultTimeFormat,
level: LogAll,
level: LogNormal,
printer: ColorPrinterClassic,
}
+3 -2
View File
@@ -24,7 +24,8 @@ const (
LogPrint
LogDebug
LogAll = LogError | LogWarn | LogEvent | LogInfo | LogHighlight | LogPrint | LogDebug
LogNormal = LogError | LogWarn | LogEvent | LogInfo | LogHighlight | LogPrint
LogAll = LogError | LogWarn | LogEvent | LogInfo | LogHighlight | LogPrint | LogDebug
)
type Log struct {
@@ -110,7 +111,7 @@ func (l *Log) WithName(name string) *Log {
func New(name string) *Log {
return &Log{
component: name,
level: LogAll,
level: LogNormal,
fmtTime: Default.fmtTime,
printer: Default.printer,
}
+22 -14
View File
@@ -9,8 +9,11 @@ import (
"github.com/gotify/go-api-client/v2/client/message"
"github.com/gotify/go-api-client/v2/gotify"
"github.com/gotify/go-api-client/v2/models"
)
"git.twelvetwelve.org/library/core/notifiers"
const (
EventMetadata = "notification::event::metadata"
EventType = "notification::event::type"
)
type Notifier struct {
@@ -18,6 +21,12 @@ type Notifier struct {
token string
}
type NotificationExtras struct {
Click struct {
URL string `json:"url"`
} `json:"click"`
}
func (n *Notifier) Notify(event string, msg string, kvs map[string]string) error {
params := message.NewCreateMessageParams()
params.Body = &models.MessageExternal{
@@ -27,22 +36,17 @@ func (n *Notifier) Notify(event string, msg string, kvs map[string]string) error
Extras: make(map[string]interface{}),
}
params.Body.Extras[EventType] = event
params.Body.Extras[EventMetadata] = kvs
for k, v := range kvs {
switch k {
case notifiers.MetadataURL:
params.Body.Extras["client::notification"] = struct {
Click struct {
URL string `json:"url"`
}
}{
Click: struct {
URL string `json:"url"`
}{
URL: v,
},
}
case "url", "metadata.url":
extras := NotificationExtras{}
extras.Click.URL = v
params.Body.Extras["client::notification"] = &extras
default:
params.Body.Extras[k] = v
continue
}
}
@@ -50,6 +54,10 @@ func (n *Notifier) Notify(event string, msg string, kvs map[string]string) error
return err
}
func (n *Notifier) Client() *client.GotifyREST {
return n.client
}
func NewNotifier(serverUrl, token string) *Notifier {
uri, _ := url.Parse(serverUrl)
client := gotify.NewClient(uri, &http.Client{})
+28
View File
@@ -1,5 +1,13 @@
package notifiers
import (
"fmt"
"strings"
"git.twelvetwelve.org/library/core/notifiers/colornotifier"
"git.twelvetwelve.org/library/core/notifiers/gotify"
)
const (
MetadataURL = "metadata.url"
)
@@ -11,5 +19,25 @@ type Notification struct {
}
type Notifier interface {
// Notify sends a notificatioin event containg a message and the set of kv pairs
// Event must contain a usable event id string we recommend using https://en.wikipedia.org/wiki/Uniform_Type_Identifier
Notify(event string, message string, kvs map[string]string) error
}
func NewNotifier(kind string, options map[string]string) (Notifier, error) {
switch strings.ToLower(kind) {
case "slack":
return NewSlackNotifier(options["Hook"]).WithIcon(options["Icon"]).WithUsername(options["User"]).WithChannel(options["Channel"]), nil
case "speaker":
//sc.notifier = speaker.NewSpeakerNotifier(sc.Options["Token"], sc.Options["Host"])
//nm.notifiers[sc.ID] = sc
case "gotify":
return gotify.NewNotifier(options["Host"], options["Token"]), nil
case "log":
return colornotifier.New(), nil
}
return nil, fmt.Errorf("unknown notifier %s", kind)
}
+31
View File
@@ -0,0 +1,31 @@
package notifiers
import (
"fmt"
"sort"
"time"
)
type LogNotifier struct{}
func (s *LogNotifier) Notify(event string, message string, kvs map[string]string) error {
fmt.Printf("%s:%s: %s\n", time.Now().Format(time.Stamp), event, message)
keys := make([]string, 0, len(kvs))
for k := range kvs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v, _ := kvs[k]
if v == "" {
continue
}
fmt.Printf(" %s: %s\n", k, v)
}
return nil
}
func NewLogNotifier() *LogNotifier {
return &LogNotifier{}
}
+79
View File
@@ -0,0 +1,79 @@
package notifiers
import (
"fmt"
"time"
)
type NotifierState struct {
Muter
notifier Notifier
}
type Multiplexer struct {
notifiers map[string]*NotifierState
}
func (m *Multiplexer) Notify(event string, message string, kvs map[string]string) error {
var lastErr error
for _, node := range m.notifiers {
if node.IsMuted() {
continue
}
err := node.notifier.Notify(event, message, kvs)
if err != nil {
lastErr = err
}
}
return lastErr
}
func (m *Multiplexer) WithNotifier(n Notifier) *Multiplexer {
m.AddNotifier(fmt.Sprintf("%v", n), n)
return m
}
func (m *Multiplexer) AddNotifier(id string, n Notifier) {
m.notifiers[id] = &NotifierState{
Muter: Muter{
Muted: false,
},
notifier: n,
}
}
func (m *Multiplexer) Mute(id string, duration time.Duration) {
if node, ok := m.notifiers[id]; ok {
node.Mute(duration)
}
}
func (m *Multiplexer) UnMute(id string) {
if node, ok := m.notifiers[id]; ok {
node.Unmute()
}
}
/*
func (m *Multiplexer) List() []NotificationConfig {
list := make([]NotificationConfig, 0, len(m.notifiers))
for k := range m.notifiers {
nc := m.notifiers[k]
list = append(list, *nc)
}
sort.Slice(list, func(i, j int) bool {
return list[i].ID < list[j].ID
})
return list
}*/
func NewMultiplexer() *Multiplexer {
m := &Multiplexer{
notifiers: make(map[string]*NotifierState),
}
return m
}
+35
View File
@@ -0,0 +1,35 @@
package notifiers
import (
"time"
)
type Muter struct {
Muted bool
MutedUntil time.Time
}
func (m *Muter) Mute(duration time.Duration) {
m.Muted = true
if duration != 0 {
m.MutedUntil = time.Now().Add(duration)
}
}
func (m *Muter) Unmute() {
m.Muted = false
m.MutedUntil = time.Time{}
}
func (m *Muter) IsMuted() bool {
if m.MutedUntil.IsZero() {
return m.Muted
}
if time.Now().After(m.MutedUntil) {
m.Muted = false
m.MutedUntil = time.Time{}
}
return m.Muted
}
+66
View File
@@ -0,0 +1,66 @@
package notifiers
import (
"sync"
"time"
"git.twelvetwelve.org/library/core/log"
)
type NotificationRecord struct {
Timestamp time.Time
Event string
Message string
Link string
KVs map[string]string
}
type Recorder struct {
max int
notifications []NotificationRecord
lock sync.Mutex
}
func (s *Recorder) List() []NotificationRecord {
s.lock.Lock()
defer s.lock.Unlock()
log.Debugf("Recording list: %d\n", len(s.notifications))
response := make([]NotificationRecord, len(s.notifications))
copy(response, s.notifications)
return response
}
func (s *Recorder) Notify(event string, message string, kvs map[string]string) error {
s.lock.Lock()
defer s.lock.Unlock()
log.Debugf("Recording notification: %s\n", message)
if kvs == nil {
kvs = make(map[string]string)
}
link, _ := kvs[MetadataURL]
s.notifications = append(s.notifications, NotificationRecord{
Timestamp: time.Now(),
Event: event,
Message: message,
Link: link,
KVs: kvs,
})
if len(s.notifications) > s.max {
s.notifications = s.notifications[1:]
}
return nil
}
func NewRecorder(max int) *Recorder {
return &Recorder{
notifications: make([]NotificationRecord, 0),
max: max,
}
}
+139
View File
@@ -0,0 +1,139 @@
package notifiers
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"sort"
)
var (
FormatPlainText = "plain_text"
FormatMarkdown = "mrkdwn"
)
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
type Attachment struct {
Fallback string `json:"fallback"`
Color string `json:"color"`
PreText string `json:"pretext"`
AuthorName string `json:"author_name"`
AuthorLink string `json:"author_link"`
AuthorIcon string `json:"author_icon"`
Title string `json:"title"`
TitleLink string `json:"title_link"`
Text string `json:"text"`
ImageUrl string `json:"image_url"`
Fields []Field `json:"fields"`
Footer string `json:"footer"`
FooterIcon string `json:"footer_icon"`
}
func (attachment *Attachment) AddField(field Field) {
attachment.Fields = append(attachment.Fields, field)
}
type Payload struct {
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
Text string `json:"text,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
}
type SlackNotifier struct {
webhook string
icon string
user string
channel string
}
func (payload *Payload) Attach(attachment Attachment) {
payload.Attachments = append(payload.Attachments, attachment)
}
func PostWebhook(url string, p *Payload) error {
json, err := json.Marshal(p)
if err != nil {
return err
}
resp, err := http.Post(url, "application/json", bytes.NewReader(json))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
return fmt.Errorf("status code: %d, response body: %s", resp.StatusCode, body)
}
return nil
}
func (s *SlackNotifier) Notify(event string, message string, kvs map[string]string) error {
p := Payload{
IconEmoji: s.icon,
Username: s.user,
Channel: s.channel,
}
a := Attachment{
Fallback: message,
Title: message,
}
keys := make([]string, 0, len(kvs))
for k := range kvs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v, _ := kvs[k]
if v == "" {
continue
}
a.AddField(Field{
Title: k,
Value: v,
Short: true,
})
}
p.Attach(a)
return PostWebhook(s.webhook, &p)
}
func (s *SlackNotifier) WithIcon(icon string) *SlackNotifier {
s.icon = icon
return s
}
func (s *SlackNotifier) WithUsername(user string) *SlackNotifier {
s.user = user
return s
}
func (s *SlackNotifier) WithChannel(channel string) *SlackNotifier {
s.channel = channel
return s
}
func NewSlackNotifier(uri string) *SlackNotifier {
return &SlackNotifier{
webhook: uri,
icon: ":large_yellow_circle:",
user: "Border Patrol",
channel: "#alerts",
}
}
-48
View File
@@ -1,48 +0,0 @@
package user
import (
"encoding/json"
"os"
"path"
"github.com/mitchellh/go-homedir"
)
func ReadUserConfig(appName, configName string, config interface{}) error {
dir, err := homedir.Dir()
if err != nil {
return err
}
filePath := path.Join(dir, ".config", appName, configName)
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
return json.Unmarshal(data, config)
}
func WriteUserConfig(appName, configName string, config interface{}) error {
dir, err := homedir.Dir()
if err != nil {
return err
}
data, err := json.Marshal(config)
if err != nil {
return err
}
filePath := path.Join(dir, ".config", appName)
err = os.MkdirAll(filePath, 0700)
if err != nil {
return err
}
filePath = path.Join(filePath, configName)
return os.WriteFile(filePath, data, 0700)
}
+98
View File
@@ -0,0 +1,98 @@
package userconfig
import (
"encoding/json"
"os"
"path"
)
var defaultSearchBase = []string{".ssh"}
// UserConfig represents configuration settings for managing user-specific configuration files.
type UserConfig struct {
homeDir string
appName string
perm os.FileMode
searchBase []string
}
// makePath constructs a file path by combining the home directory, search path, application name (if set), and config filename.
func (c *UserConfig) makePath(search, config string) string {
if c.appName != "" {
return path.Join(c.homeDir, search, c.appName, config)
}
return path.Join(c.homeDir, search, config)
}
// WithAppName sets the application name for the configuration paths and returns the updated UserConfig.
func (c *UserConfig) WithAppName(appName string) *UserConfig {
c.appName = appName
return c
}
// WithSearchPaths sets the search paths for configuration files and returns the updated UserConfig.
func (c *UserConfig) WithSearchPaths(base []string) *UserConfig {
c.searchBase = base
return c
}
// WithPermission sets the file permission mode for created configuration files and returns the updated UserConfig.
func (c *UserConfig) WithPermission(perm os.FileMode) *UserConfig {
c.perm = perm
return c
}
// Read searches for a configuration file by name in specified search paths and unmarshals its content into the provided object.
func (c *UserConfig) Read(configName string, config interface{}) error {
if c.homeDir == "" {
return os.ErrNotExist
}
for _, search := range c.searchBase {
filePath := c.makePath(search, configName)
if _, err := os.Stat(filePath); err == nil {
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
return json.Unmarshal(data, config)
}
}
return os.ErrNotExist
}
// Write saves the provided configuration object to a file with the given name in the first available search path.
func (c *UserConfig) Write(configName string, config interface{}) error {
if c.homeDir == "" {
return os.ErrNotExist
}
if len(c.searchBase) == 0 {
return os.ErrNotExist
}
search := c.searchBase[0]
filePath := c.makePath(search, configName)
data, err := json.Marshal(config)
if err != nil {
return err
}
return os.WriteFile(filePath, data, c.perm)
}
// New initializes and returns a new instance of UserConfig with default settings including home directory and permissions.
func New() *UserConfig {
dir, err := os.UserHomeDir()
if err != nil {
return &UserConfig{}
}
return &UserConfig{
homeDir: dir,
perm: 0600,
searchBase: defaultSearchBase,
}
}