Speak the ntfy JSON API directly: subscribe, publish, group topics that share a server and credentials, reconnect with backoff and a since cursor, and list account subscriptions, reservations, and ACL grants.
225 lines
4.7 KiB
Go
225 lines
4.7 KiB
Go
package ntfy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"lazyntfy/internal/config"
|
|
)
|
|
|
|
// Notice is a stream lifecycle update or a decoded event.
|
|
type Notice struct {
|
|
Event *Event
|
|
Topics []string
|
|
Status string
|
|
Up bool
|
|
UpSet bool
|
|
}
|
|
|
|
// Manager keeps one reconnecting JSON stream per topic group.
|
|
type Manager struct {
|
|
client *Client
|
|
out chan Notice
|
|
reload chan struct{}
|
|
mu sync.Mutex
|
|
ctx context.Context
|
|
subs []config.Sub
|
|
since string
|
|
cursors map[string]int64
|
|
backoff time.Duration
|
|
maxBackoff time.Duration
|
|
}
|
|
|
|
// NewManager starts with no subscriptions until Start is called.
|
|
func NewManager(client *Client) *Manager {
|
|
if client == nil {
|
|
client = NewClient()
|
|
}
|
|
return &Manager{
|
|
client: client,
|
|
out: make(chan Notice, 256),
|
|
reload: make(chan struct{}, 1),
|
|
cursors: map[string]int64{},
|
|
backoff: time.Second,
|
|
maxBackoff: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// SetBackoff overrides reconnect delays. Tests use a short interval.
|
|
func (m *Manager) SetBackoff(initial, max time.Duration) {
|
|
m.backoff = initial
|
|
m.maxBackoff = max
|
|
}
|
|
|
|
// Prime records already-seen newest times per origin/topic so the first stream
|
|
// does not replay history the client restored from disk. Keys use the same
|
|
// origin + "/" + topic format the manager tracks internally.
|
|
func (m *Manager) Prime(cursors map[string]int64) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for key, ts := range cursors {
|
|
if ts > m.cursors[key] {
|
|
m.cursors[key] = ts
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notices is the stream of events and status lines. It is never closed.
|
|
func (m *Manager) Notices() <-chan Notice { return m.out }
|
|
|
|
// Start subscribes until ctx is cancelled. Call it once.
|
|
func (m *Manager) Start(ctx context.Context, subs []config.Sub, since string) {
|
|
m.mu.Lock()
|
|
m.ctx = ctx
|
|
m.subs = append([]config.Sub(nil), subs...)
|
|
m.since = since
|
|
m.mu.Unlock()
|
|
go m.run()
|
|
}
|
|
|
|
// Reload drops the current connections and subscribes again.
|
|
func (m *Manager) Reload() {
|
|
select {
|
|
case m.reload <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Add appends a topic for this session and reconnects.
|
|
func (m *Manager) Add(sub config.Sub) {
|
|
m.mu.Lock()
|
|
m.subs = append(m.subs, sub)
|
|
m.mu.Unlock()
|
|
m.Reload()
|
|
}
|
|
|
|
func (m *Manager) run() {
|
|
ctx := m.ctx
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
subs := append([]config.Sub(nil), m.subs...)
|
|
m.mu.Unlock()
|
|
child, cancel := context.WithCancel(ctx)
|
|
var wg sync.WaitGroup
|
|
if len(subs) == 0 {
|
|
m.emit(child, Notice{Status: "no topics"})
|
|
}
|
|
for _, group := range GroupSubs(subs) {
|
|
wg.Add(1)
|
|
go func(g Group) {
|
|
defer wg.Done()
|
|
m.supervise(child, g)
|
|
}(group)
|
|
}
|
|
stopped := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(stopped)
|
|
}()
|
|
select {
|
|
case <-ctx.Done():
|
|
cancel()
|
|
<-stopped
|
|
return
|
|
case <-m.reload:
|
|
cancel()
|
|
<-stopped
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) supervise(ctx context.Context, g Group) {
|
|
delay := m.backoff
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
err := m.consume(ctx, g)
|
|
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
|
|
return
|
|
}
|
|
status := "disconnected, retrying"
|
|
if err != nil {
|
|
status = err.Error()
|
|
}
|
|
m.emit(ctx, Notice{Topics: g.Names, Status: status, Up: false, UpSet: true})
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
if delay < m.maxBackoff {
|
|
delay *= 2
|
|
if delay > m.maxBackoff {
|
|
delay = m.maxBackoff
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) consume(ctx context.Context, g Group) error {
|
|
resp, meta, err := m.client.OpenStream(ctx, g.URL(m.sinceFor(g)), g.Auth)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
m.emit(ctx, Notice{Topics: g.Names, Status: "connected", Up: true, UpSet: true})
|
|
if meta.Truncated {
|
|
m.emit(ctx, Notice{Topics: g.Names, Status: "cache truncated to the newest messages", Up: true, UpSet: true})
|
|
}
|
|
return ReadLines(resp.Body, func(ev Event) error {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
m.note(g.Origin, ev)
|
|
copyEv := ev
|
|
m.emit(ctx, Notice{Event: ©Ev, Topics: []string{ev.Topic}})
|
|
return nil
|
|
}, func(err error) {
|
|
m.emit(ctx, Notice{Status: "skipped malformed event"})
|
|
})
|
|
}
|
|
|
|
func (m *Manager) sinceFor(g Group) string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var max int64
|
|
for _, name := range g.Names {
|
|
if ts := m.cursors[g.Origin+"/"+name]; ts > max {
|
|
max = ts
|
|
}
|
|
}
|
|
if max == 0 {
|
|
return m.since
|
|
}
|
|
return strconv.FormatInt(max, 10)
|
|
}
|
|
|
|
func (m *Manager) note(origin string, ev Event) {
|
|
if ev.Time == 0 || ev.Topic == "" || strings.Contains(ev.Topic, ",") {
|
|
return
|
|
}
|
|
key := origin + "/" + ev.Topic
|
|
m.mu.Lock()
|
|
if ev.Time > m.cursors[key] {
|
|
m.cursors[key] = ev.Time
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *Manager) emit(ctx context.Context, n Notice) {
|
|
select {
|
|
case <-ctx.Done():
|
|
case m.out <- n:
|
|
}
|
|
}
|