Config UI: make messengers configurable (#26946)
Co-authored-by: andig <cpuidle@gmail.com> Co-authored-by: andig <andi@evcc.io> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
66c31118ad
commit
7f772bad73
60 changed files with 1547 additions and 363 deletions
28
messenger/config.go
Normal file
28
messenger/config.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
reg "github.com/evcc-io/evcc/util/registry"
|
||||
)
|
||||
|
||||
var registry = reg.New[api.Messenger]("messenger")
|
||||
|
||||
// NewFromConfig creates messenger from configuration
|
||||
func NewFromConfig(ctx context.Context, typ string, other map[string]any) (api.Messenger, error) {
|
||||
factory, err := registry.Get(strings.ToLower(typ))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := factory(ctx, other)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("cannot create messenger type '%s': %w", util.TypeWithTemplateName(typ, other), err)
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
149
messenger/hub.go
Normal file
149
messenger/hub.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/api/globalconfig"
|
||||
"github.com/evcc-io/evcc/core/vehicle"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// Event is a notification event
|
||||
type Event struct {
|
||||
Loadpoint *int // optional loadpoint id
|
||||
Event string
|
||||
}
|
||||
|
||||
type Vehicles interface {
|
||||
// ByName returns a single vehicle adapter by name
|
||||
ByName(string) (vehicle.API, error)
|
||||
}
|
||||
|
||||
// Hub subscribes to event notifications and sends them to client devices
|
||||
type Hub struct {
|
||||
definitions globalconfig.MessagingEvents
|
||||
sender []api.Messenger
|
||||
cache *util.ParamCache
|
||||
vehicles Vehicles
|
||||
}
|
||||
|
||||
// NewHub creates push hub with definitions and receiver
|
||||
func NewHub(cc globalconfig.MessagingEvents, vv Vehicles, cache *util.ParamCache) (*Hub, error) {
|
||||
// keep only enabled events
|
||||
filtered := make(globalconfig.MessagingEvents, len(cc))
|
||||
|
||||
for k, v := range cc {
|
||||
if !v.Disabled {
|
||||
filtered[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// instantiate all event templates
|
||||
for k, v := range filtered {
|
||||
if _, err := template.New("out").Funcs(sprig.FuncMap()).Parse(v.Title); err != nil {
|
||||
return nil, fmt.Errorf("invalid event title: %s (%w)", k, err)
|
||||
}
|
||||
if _, err := template.New("out").Funcs(sprig.FuncMap()).Parse(v.Msg); err != nil {
|
||||
return nil, fmt.Errorf("invalid event message: %s (%w)", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
h := &Hub{
|
||||
definitions: cc,
|
||||
cache: cache,
|
||||
vehicles: vv,
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Add adds a sender to the list of senders
|
||||
func (h *Hub) Add(sender api.Messenger) {
|
||||
h.sender = append(h.sender, sender)
|
||||
}
|
||||
|
||||
// apply applies the event template to the content to produce the actual message
|
||||
func (h *Hub) apply(ev Event, tmpl string) (string, error) {
|
||||
attr := make(map[string]any)
|
||||
|
||||
// loadpoint id
|
||||
if ev.Loadpoint != nil {
|
||||
attr["loadpoint"] = *ev.Loadpoint + 1
|
||||
}
|
||||
|
||||
// get all values from cache
|
||||
for _, p := range h.cache.All() {
|
||||
if p.Loadpoint == nil || ev.Loadpoint == p.Loadpoint {
|
||||
val := p.Val
|
||||
|
||||
// resolve pointers (https://github.com/evcc-io/evcc/issues/24688)
|
||||
if rv := reflect.ValueOf(p.Val); rv.Kind() == reflect.Pointer && !rv.IsNil() {
|
||||
val = rv.Elem().Interface()
|
||||
}
|
||||
|
||||
attr[p.Key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// add missing attributes
|
||||
if name, ok := attr["vehicleName"].(string); ok {
|
||||
if v, err := h.vehicles.ByName(name); err == nil {
|
||||
attr["vehicleLimitSoc"] = v.GetLimitSoc()
|
||||
attr["vehicleMinSoc"] = v.GetMinSoc()
|
||||
attr["vehiclePlanTime"], attr["vehiclePlanSoc"] = v.GetPlanSoc()
|
||||
|
||||
instance := v.Instance()
|
||||
attr["vehicleTitle"] = instance.GetTitle()
|
||||
attr["vehicleIcon"] = instance.Icon()
|
||||
attr["vehicleCapacity"] = instance.Capacity()
|
||||
}
|
||||
}
|
||||
|
||||
return util.ReplaceFormatted(tmpl, attr)
|
||||
}
|
||||
|
||||
// Run is the Hub's main publishing loop
|
||||
func (h *Hub) Run(events <-chan Event, valueChan chan<- util.Param) {
|
||||
log := util.NewLogger("push")
|
||||
|
||||
for ev := range events {
|
||||
if len(h.sender) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
definition, ok := h.definitions[ev.Event]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// let cache catch up, refs https://github.com/evcc-io/evcc/pull/445
|
||||
flushC := util.Flusher()
|
||||
valueChan <- util.Param{Val: flushC}
|
||||
<-flushC
|
||||
|
||||
title, err := h.apply(ev, definition.Title)
|
||||
if err != nil {
|
||||
log.ERROR.Printf("invalid title template for %s: %v", ev.Event, err)
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := h.apply(ev, definition.Msg)
|
||||
if err != nil {
|
||||
log.ERROR.Printf("invalid message template for %s: %v", ev.Event, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sender := range h.sender {
|
||||
go sender.Send(title, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
messenger/messenger.go
Normal file
91
messenger/messenger.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.AddCtx(api.Custom, NewConfigurableFromConfig)
|
||||
}
|
||||
|
||||
// NewConfigurableFromConfig creates Messenger from config
|
||||
func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
|
||||
var cc struct {
|
||||
Send plugin.Config
|
||||
Encoding string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
send, err := cc.Send.StringSetter(ctx, "send")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewConfigurable(send, cc.Encoding)
|
||||
}
|
||||
|
||||
// NewConfigurable creates a new Messenger
|
||||
func NewConfigurable(send func(string) error, encoding string) (*Push, error) {
|
||||
m := &Push{
|
||||
log: util.NewLogger("push"),
|
||||
send: send,
|
||||
encoding: strings.ToLower(encoding),
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Push is a configurable Messenger implementation
|
||||
type Push struct {
|
||||
log *util.Logger
|
||||
send func(string) error
|
||||
encoding string
|
||||
}
|
||||
|
||||
func (m *Push) csv(separator rune, title, msg string) string {
|
||||
var b bytes.Buffer
|
||||
ww := csv.NewWriter(&b)
|
||||
ww.Comma = separator
|
||||
_ = ww.Write([]string{title, msg})
|
||||
ww.Flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Send implements the Messenger interface
|
||||
func (m *Push) Send(title, msg string) {
|
||||
var res string
|
||||
|
||||
switch m.encoding {
|
||||
case "json":
|
||||
b, _ := json.Marshal(struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Msg string `json:"msg"`
|
||||
}{
|
||||
Title: title,
|
||||
Msg: msg,
|
||||
})
|
||||
res = string(b)
|
||||
case "csv":
|
||||
res = m.csv(',', title, msg)
|
||||
case "tsv":
|
||||
res = m.csv('\t', title, msg)
|
||||
case "title":
|
||||
res = title
|
||||
default:
|
||||
res = msg
|
||||
}
|
||||
|
||||
if err := m.send(res); err != nil {
|
||||
m.log.ERROR.Printf("send: %v", err)
|
||||
}
|
||||
}
|
||||
97
messenger/ntfy.go
Normal file
97
messenger/ntfy.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.Add("ntfy", NewNtfyFromConfig)
|
||||
}
|
||||
|
||||
// Ntfy implements the ntfy messaging aggregator
|
||||
type Ntfy struct {
|
||||
log *util.Logger
|
||||
uri string
|
||||
priority string
|
||||
tags string
|
||||
}
|
||||
|
||||
// NewNtfyFromConfig creates new Ntfy messenger
|
||||
func NewNtfyFromConfig(other map[string]any) (api.Messenger, error) {
|
||||
var cc struct {
|
||||
URI string
|
||||
Priority string
|
||||
Tags string
|
||||
AuthToken string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.URI == "" {
|
||||
return nil, errors.New("missing uri")
|
||||
}
|
||||
|
||||
u, err := url.Parse(cc.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := util.NewLogger("ntfy")
|
||||
|
||||
if cc.AuthToken != "" {
|
||||
bearer := "Bearer " + cc.AuthToken
|
||||
encoded := base64.RawStdEncoding.EncodeToString([]byte(bearer))
|
||||
|
||||
q := u.Query()
|
||||
if q.Has("auth") {
|
||||
return nil, fmt.Errorf("uri already contains auth parameter")
|
||||
}
|
||||
|
||||
q.Set("auth", encoded)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
cc.URI = u.String()
|
||||
|
||||
log = log.Redact(cc.AuthToken, bearer, encoded)
|
||||
}
|
||||
|
||||
if token, ok := strings.CutPrefix(u.String(), "https://ntfy.sh/"); ok {
|
||||
log = log.Redact(token)
|
||||
}
|
||||
|
||||
m := &Ntfy{
|
||||
log: log,
|
||||
uri: cc.URI,
|
||||
priority: cc.Priority,
|
||||
tags: cc.Tags,
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Send sends to all receivers
|
||||
func (m *Ntfy) Send(title, msg string) {
|
||||
req, err := request.New("POST", m.uri, strings.NewReader(msg), map[string]string{
|
||||
"Priority": m.priority,
|
||||
"Title": title,
|
||||
"Tags": m.tags,
|
||||
})
|
||||
if err != nil {
|
||||
m.log.ERROR.Printf("ntfy: %v", err)
|
||||
}
|
||||
|
||||
if _, err := http.DefaultClient.Do(req); err != nil {
|
||||
m.log.ERROR.Printf("ntfy: %v", err)
|
||||
}
|
||||
}
|
||||
67
messenger/pushover.go
Normal file
67
messenger/pushover.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/gregdel/pushover"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.Add("pushover", NewPushOverFromConfig)
|
||||
}
|
||||
|
||||
// PushOver implements the pushover messenger
|
||||
type PushOver struct {
|
||||
log *util.Logger
|
||||
app *pushover.Pushover
|
||||
device string
|
||||
recipients []string
|
||||
}
|
||||
|
||||
// NewPushOverFromConfig creates new pushover messenger
|
||||
func NewPushOverFromConfig(other map[string]any) (api.Messenger, error) {
|
||||
var cc struct {
|
||||
App string
|
||||
Recipients []string
|
||||
Devices []string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.App == "" {
|
||||
return nil, errors.New("missing app name")
|
||||
}
|
||||
|
||||
m := &PushOver{
|
||||
log: util.NewLogger("pushover").Redact(cc.App),
|
||||
app: pushover.New(cc.App),
|
||||
device: strings.Join(cc.Devices, ","),
|
||||
recipients: cc.Recipients,
|
||||
}
|
||||
|
||||
m.log.Redact(cc.Recipients...)
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Send sends to all receivers
|
||||
func (m *PushOver) Send(title, msg string) {
|
||||
message := pushover.NewMessageWithTitle(msg, title)
|
||||
message.DeviceName = m.device
|
||||
|
||||
for _, id := range m.recipients {
|
||||
go func(id string) {
|
||||
m.log.DEBUG.Printf("sending to %s", id)
|
||||
|
||||
recipient := pushover.NewRecipient(id)
|
||||
if _, err := m.app.SendMessage(message, recipient); err != nil {
|
||||
m.log.ERROR.Print(err)
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
}
|
||||
55
messenger/shoutrrr.go
Normal file
55
messenger/shoutrrr.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"github.com/containrrr/shoutrrr/pkg/router"
|
||||
"github.com/containrrr/shoutrrr/pkg/types"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.Add("shout", NewShoutrrrFromConfig)
|
||||
}
|
||||
|
||||
// Shoutrrr implements the shoutrrr messaging aggregator
|
||||
type Shoutrrr struct {
|
||||
log *util.Logger
|
||||
app *router.ServiceRouter
|
||||
}
|
||||
|
||||
// NewShoutrrrFromConfig creates new Shoutrrr messenger
|
||||
func NewShoutrrrFromConfig(other map[string]any) (api.Messenger, error) {
|
||||
var cc struct {
|
||||
URI string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
app, err := shoutrrr.CreateSender(cc.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := &Shoutrrr{
|
||||
log: util.NewLogger("shoutrrr"),
|
||||
app: app,
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Send sends to all receivers
|
||||
func (m *Shoutrrr) Send(title, msg string) {
|
||||
params := &types.Params{
|
||||
"title": title,
|
||||
}
|
||||
|
||||
for _, err := range m.app.Send(msg, params) {
|
||||
if err != nil {
|
||||
m.log.ERROR.Println("send:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
messenger/telegram.go
Normal file
95
messenger/telegram.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("telegram", NewTelegramFromConfig)
|
||||
}
|
||||
|
||||
// Telegram implements the Telegram messenger
|
||||
type Telegram struct {
|
||||
log *util.Logger
|
||||
sync.Mutex
|
||||
bot *bot.Bot
|
||||
chats map[int64]struct{}
|
||||
}
|
||||
|
||||
// NewTelegramFromConfig creates new pushover messenger
|
||||
func NewTelegramFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
|
||||
var cc struct {
|
||||
Token string
|
||||
Chats []int64
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := util.NewLogger("telegram").Redact(cc.Token)
|
||||
|
||||
m := &Telegram{
|
||||
log: log,
|
||||
chats: make(map[int64]struct{}),
|
||||
}
|
||||
|
||||
bot, err := bot.New(cc.Token, bot.WithDefaultHandler(m.handler), bot.WithErrorsHandler(func(err error) {
|
||||
log.ERROR.Println(err)
|
||||
}), bot.WithDebugHandler(func(format string, args ...any) {
|
||||
log.TRACE.Printf(format, args...)
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid bot token")
|
||||
}
|
||||
|
||||
m.bot = bot
|
||||
|
||||
go bot.Start(ctx)
|
||||
|
||||
for _, chat := range cc.Chats {
|
||||
log.Redact(strconv.FormatInt(chat, 10))
|
||||
m.chats[chat] = struct{}{}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handler captures ids of all chats that bot participates in
|
||||
func (m *Telegram) handler(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
if _, ok := m.chats[update.Message.Chat.ID]; !ok {
|
||||
m.log.INFO.Printf("new chat id: %d", update.Message.Chat.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends to all receivers
|
||||
func (m *Telegram) Send(title, msg string) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
for chat := range m.chats {
|
||||
m.log.DEBUG.Printf("sending to %d", chat)
|
||||
|
||||
if _, err := m.bot.SendMessage(context.Background(), &bot.SendMessageParams{
|
||||
ChatID: chat,
|
||||
Text: msg,
|
||||
}); err != nil {
|
||||
m.log.ERROR.Println("send:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
messenger/template.go
Normal file
21
messenger/template.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("template", NewFromTemplateConfig)
|
||||
}
|
||||
|
||||
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Messenger, error) {
|
||||
instance, err := templates.RenderInstance(templates.Messenger, other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewFromConfig(ctx, instance.Type, instance.Other)
|
||||
}
|
||||
24
messenger/template_test.go
Normal file
24
messenger/template_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package messenger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
"github.com/evcc-io/evcc/util/test"
|
||||
)
|
||||
|
||||
var acceptable = []string{
|
||||
// api.ErrMissingCredentials.Error(),
|
||||
// api.ErrMissingToken.Error(),
|
||||
}
|
||||
|
||||
func TestTemplates(t *testing.T) {
|
||||
templates.TestClass(t, templates.Messenger, func(t *testing.T, values map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := NewFromConfig(t.Context(), "template", values); err != nil && !test.Acceptable(err, acceptable) {
|
||||
t.Log(values)
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue