Compare commits
11
Commits
2ac73b80f6
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee9e130a92
|
||
|
|
714b2da0c7
|
||
|
|
8673f1e83a
|
||
|
|
97cc150bf7
|
||
|
|
cd19d4c665
|
||
|
|
387b3c77f3
|
||
|
|
45f18ded00
|
||
|
|
67a843b1e0
|
||
|
|
ae37b98ef8
|
||
|
|
e412819d48
|
||
|
|
eaf7766a4d
|
@@ -0,0 +1,25 @@
|
|||||||
|
# See https://editorconfig.org for the format.
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{md,markdown,yml,yaml,json}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.1]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Binaries and build output
|
||||||
|
/lazyntfy
|
||||||
|
/dist/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Go test and coverage artifacts
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.*
|
||||||
|
|
||||||
|
# Local SQLite history (only when run with a repository-local path)
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Editors and operating systems
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
BINARY := lazyntfy
|
||||||
|
PREFIX ?= $(HOME)/.local
|
||||||
|
BINDIR ?= $(PREFIX)/bin
|
||||||
|
MANDIR ?= $(PREFIX)/share/man/man1
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' | grep . || echo 0.1.0)
|
||||||
|
TOPIC ?= alerts
|
||||||
|
|
||||||
|
LDFLAGS := -X main.version=$(VERSION)
|
||||||
|
|
||||||
|
.PHONY: build install uninstall test vet fmt run man clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/lazyntfy
|
||||||
|
|
||||||
|
install: build
|
||||||
|
mkdir -p "$(BINDIR)" "$(MANDIR)"
|
||||||
|
install -m 755 $(BINARY) "$(BINDIR)/$(BINARY)"
|
||||||
|
install -m 644 man/lazyntfy.1 "$(MANDIR)/lazyntfy.1"
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
rm -f "$(BINDIR)/$(BINARY)" "$(MANDIR)/lazyntfy.1"
|
||||||
|
|
||||||
|
man:
|
||||||
|
mkdir -p "$(MANDIR)"
|
||||||
|
install -m 644 man/lazyntfy.1 "$(MANDIR)/lazyntfy.1"
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
gofmt -w .
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./$(BINARY) $(TOPIC)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY)
|
||||||
@@ -1,2 +1,174 @@
|
|||||||
# lazyntfy
|
# lazyntfy
|
||||||
|
|
||||||
|
A [lazygit](https://github.com/jesseduffield/lazygit)-style terminal client for
|
||||||
|
[ntfy](https://ntfy.sh): topics in the left pane, messages in the middle pane,
|
||||||
|
and the selected message in the right pane.
|
||||||
|
|
||||||
|
```
|
||||||
|
╭ topics ─────╮ ╭ messages ─────────────────╮ ╭ detail ───────────────╮
|
||||||
|
│ ● alerts 12 │ │ 06-14 09:21 CRIT DiskFull │ │ CRITICAL │
|
||||||
|
│ ○ backups │ │ 06-14 09:03 high Backup… │ │ DiskFull │
|
||||||
|
│ ○ deploy │ │ 06-13 22:47 def Deploy… │ │ 2026-06-14 09:21:07 │
|
||||||
|
│ │ │ │ │ mK3n9Qx… │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
╰─────────────╯ ╰────────────────────────────╯ ╰───────────────────────╯
|
||||||
|
lazyntfy alerts 12/12 ● connected ? help q quit
|
||||||
|
```
|
||||||
|
|
||||||
|
lazyntfy talks to the ntfy HTTP API directly (`GET /topic/json` to subscribe,
|
||||||
|
`POST /topic` to publish). It reads `~/.config/ntfy/client.yml` for credentials
|
||||||
|
and subscriptions, groups topics that share a server and credentials onto one
|
||||||
|
stream, and reconnects automatically — resuming from the newest event it has
|
||||||
|
already seen so nothing is delivered twice.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **lazygit-style layout** — topics, messages, and message detail in three panes.
|
||||||
|
- **Account discovery** — with no topic on the command line it lists your
|
||||||
|
subscriptions, reservations, and ACL grants.
|
||||||
|
- **Alert-aware** — Grafana and Alertmanager webhook bodies are summarized
|
||||||
|
(status, alert name, severity, summary); `v` shows the raw JSON.
|
||||||
|
- **Persistent** — topics you add and the messages that arrive are stored in a
|
||||||
|
small SQLite file and restored on the next start.
|
||||||
|
- **Resilient** — reconnects with exponential backoff, honoring `message_delete`
|
||||||
|
and `message_clear` events.
|
||||||
|
- **Single binary, no CGo** — pure Go, including the SQLite driver.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Go 1.26 or newer (to build)
|
||||||
|
- A reachable ntfy server
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://src.panahifar.ir/ahp/lazyntfy.git
|
||||||
|
cd lazyntfy
|
||||||
|
make install # installs the binary and the man page under ~/.local
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install` honors `PREFIX` (default `~/.local`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make install PREFIX=/usr/local
|
||||||
|
```
|
||||||
|
|
||||||
|
### With `go install`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install ./cmd/lazyntfy@latest # or: go install lazyntfy/cmd/lazyntfy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
lazyntfy # topics from the account, their history, and client.yml
|
||||||
|
lazyntfy alerts # one topic, expanded with default-host
|
||||||
|
lazyntfy --since 1h alerts # replay the last hour
|
||||||
|
lazyntfy --host https://ntfy.example alerts
|
||||||
|
lazyntfy --token tk_xxx alerts
|
||||||
|
lazyntfy --user name:pass alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
With no topic on the command line, lazyntfy asks the server for your account
|
||||||
|
subscriptions, reserved topics, and ACL grants (`GET /v1/account` and, when
|
||||||
|
allowed, `GET /v1/users`), then adds the topics remembered from previous
|
||||||
|
sessions. You can always add a topic for the current session with `a`.
|
||||||
|
|
||||||
|
## Key bindings
|
||||||
|
|
||||||
|
Keys act on the focused pane. Use `tab` / `shift-tab` or `1` / `2` / `3` to
|
||||||
|
change panes.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `k`, arrows | move in the focused pane |
|
||||||
|
| `g` / `G` | first / last |
|
||||||
|
| `ctrl-u` / `ctrl-d` | page up / down |
|
||||||
|
| `1` / `2` / `3` | focus topics, messages, detail |
|
||||||
|
| `tab` / `shift-tab` | next / previous pane |
|
||||||
|
| `enter` | topics → messages → detail |
|
||||||
|
| `esc` | back from detail, or clear the filter |
|
||||||
|
| `/` | filter title, body, tags, and id |
|
||||||
|
| `r` | reconnect |
|
||||||
|
| `p` | publish to the focused topic |
|
||||||
|
| `a` | subscribe to another topic for this session |
|
||||||
|
| `v` | toggle raw JSON |
|
||||||
|
| `o` | open the message click URL |
|
||||||
|
| `?` | help |
|
||||||
|
| `q` | quit |
|
||||||
|
|
||||||
|
On a narrow terminal the detail pane is hidden until it is focused.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
lazyntfy reads the official ntfy client configuration at
|
||||||
|
`~/.config/ntfy/client.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
default-host: https://ntfy.example
|
||||||
|
default-token: tk_xxxxxxxxxxxxxxxx
|
||||||
|
subscribe:
|
||||||
|
- topic: alerts
|
||||||
|
- topic: https://other.example/backups
|
||||||
|
user: alice
|
||||||
|
password: secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Positional topics on the command line replace the file's `subscribe` list.
|
||||||
|
Credentials are sent in the `Authorization` header and are never printed or
|
||||||
|
written back. See `man lazyntfy` for every flag.
|
||||||
|
|
||||||
|
## History and storage
|
||||||
|
|
||||||
|
Topics you add and the messages that arrive are kept in a small SQLite database
|
||||||
|
at `$XDG_DATA_HOME/lazyntfy/history.db`
|
||||||
|
(`~/.local/share/lazyntfy/history.db` by default), so the last session is
|
||||||
|
restored on the next start. Each topic keeps its newest 1000 messages; expired
|
||||||
|
messages are pruned on startup. Credentials are never written to the database —
|
||||||
|
the current run's authentication is always used. History is best effort: if the
|
||||||
|
file cannot be opened, the client still runs.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make build # build ./lazyntfy
|
||||||
|
make test # go test ./...
|
||||||
|
make vet # go vet ./...
|
||||||
|
make fmt # gofmt -w .
|
||||||
|
make run TOPIC=alerts
|
||||||
|
make man # install just the man page
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single package's tests, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/history/...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `cmd/lazyntfy` | flags and program startup |
|
||||||
|
| `internal/config` | `client.yml` plus CLI overrides |
|
||||||
|
| `internal/history` | on-disk topics and messages (SQLite) |
|
||||||
|
| `internal/ntfy` | HTTP subscribe, publish, reconnect |
|
||||||
|
| `internal/store` | newest-first messages, delete, clear |
|
||||||
|
| `internal/alert` | Grafana / Alertmanager summary |
|
||||||
|
| `internal/theme` | Catppuccin adaptive palette |
|
||||||
|
| `internal/textutil` | width-aware text wrapping and clipping |
|
||||||
|
| `internal/tui` | Bubble Tea interface |
|
||||||
|
| `man` | `lazyntfy(1)` man page |
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository and create a topic branch.
|
||||||
|
2. Keep commits focused and conventional (`feat:`, `fix:`, `docs:`, …).
|
||||||
|
3. Run `make fmt vet test` before opening a pull request.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GNU General Public License, version 3 or later. See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/history"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
"lazyntfy/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is set at build time with -ldflags "-X main.version=...".
|
||||||
|
var version = "0.1.0"
|
||||||
|
|
||||||
|
const usageText = `lazyntfy is a terminal client for ntfy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
lazyntfy [flags] [topic...]
|
||||||
|
|
||||||
|
With no topics, lazyntfy lists subscriptions, reservations, and grants
|
||||||
|
from the ntfy account, plus any subscribe: entries in client.yml.
|
||||||
|
Short topic names expand with default-host.
|
||||||
|
A token or user from that file, or from --token / --user, is sent as
|
||||||
|
Authorization and is never printed or written back.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--config path client.yml path
|
||||||
|
--host url override default-host
|
||||||
|
--token token override default-token
|
||||||
|
--user user[:pass]
|
||||||
|
--since value replay window (default all)
|
||||||
|
--version
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run(os.Args[1:], os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stderr io.Writer) int {
|
||||||
|
opt, ver, err := parseArgs(args, stderr)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if ver {
|
||||||
|
fmt.Println("lazyntfy", version)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
app, err := config.Load(opt)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
client := ntfy.NewClient()
|
||||||
|
|
||||||
|
hist := openHistory()
|
||||||
|
if hist != nil {
|
||||||
|
defer hist.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opt.Topics) == 0 {
|
||||||
|
found, err := client.AccountTopics(ctx, app.DefaultHost, app.Auth)
|
||||||
|
if err != nil {
|
||||||
|
app.StartupStatus = err.Error()
|
||||||
|
} else {
|
||||||
|
app.Subs = config.MergeSubs(app.Subs, found)
|
||||||
|
}
|
||||||
|
app.Subs = config.MergeSubs(app.Subs, rememberedSubs(hist, app.DefaultHost, app.Auth))
|
||||||
|
}
|
||||||
|
if len(app.Subs) == 0 && app.StartupStatus == "" {
|
||||||
|
app.StartupStatus = "no topics yet — press a to add one"
|
||||||
|
}
|
||||||
|
mgr := ntfy.NewManager(client)
|
||||||
|
if app.Since == "all" {
|
||||||
|
mgr.Prime(newestFromHistory(hist, app.Subs))
|
||||||
|
}
|
||||||
|
mgr.Start(ctx, app.Subs, app.Since)
|
||||||
|
|
||||||
|
p := tea.NewProgram(tui.New(app, mgr.Notices(), client, mgr, hist))
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// openHistory opens the on-disk history. It is best effort: when it fails the
|
||||||
|
// app still runs, it just does not remember this session.
|
||||||
|
func openHistory() *history.DB {
|
||||||
|
path := history.DefaultPath()
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db, err := history.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberedSubs returns topics recorded by earlier sessions. Credentials are
|
||||||
|
// never read back from disk; the current run's auth is applied instead.
|
||||||
|
func rememberedSubs(hist *history.DB, defaultHost string, auth config.Auth) []config.Sub {
|
||||||
|
if hist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
topics, err := hist.Topics()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []config.Sub
|
||||||
|
for _, sub := range topics {
|
||||||
|
if sub.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sub.Origin == "" {
|
||||||
|
origin, name, err := config.Resolve(sub.Name, defaultHost)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sub.Origin, sub.Name = origin, name
|
||||||
|
}
|
||||||
|
sub.Auth = auth
|
||||||
|
out = append(out, sub)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// newestFromHistory returns the newest persisted event time per origin/topic,
|
||||||
|
// so the first stream can carry on where the last session stopped instead of
|
||||||
|
// replaying the server's whole cache.
|
||||||
|
func newestFromHistory(hist *history.DB, subs []config.Sub) map[string]int64 {
|
||||||
|
if hist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
msgs, err := hist.Messages(sub.Name, 1)
|
||||||
|
if err != nil || len(msgs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := sub.Origin + "/" + sub.Name
|
||||||
|
if msgs[0].Time > out[key] {
|
||||||
|
out[key] = msgs[0].Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgs(args []string, stderr io.Writer) (config.Options, bool, error) {
|
||||||
|
fs := flag.NewFlagSet("lazyntfy", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
fs.Usage = func() { fmt.Fprint(stderr, usageText) }
|
||||||
|
configPath := fs.String("config", "", "ntfy client.yml path")
|
||||||
|
host := fs.String("host", "", "override default-host")
|
||||||
|
token := fs.String("token", "", "override default-token")
|
||||||
|
user := fs.String("user", "", "user[:password]")
|
||||||
|
since := fs.String("since", "all", "replay window: all, duration, unix timestamp, or message id")
|
||||||
|
ver := fs.Bool("version", false, "print version")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return config.Options{}, false, err
|
||||||
|
}
|
||||||
|
return config.Options{
|
||||||
|
Path: *configPath,
|
||||||
|
PathSet: *configPath != "",
|
||||||
|
Host: *host,
|
||||||
|
Token: *token,
|
||||||
|
User: *user,
|
||||||
|
Since: *since,
|
||||||
|
Topics: fs.Args(),
|
||||||
|
}, *ver, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/history"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseArgsTopics(t *testing.T) {
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
opt, ver, err := parseArgs([]string{"--since", "1h", "--host", "https://ntfy.example", "alerts", "backups"}, &errBuf)
|
||||||
|
if err != nil || ver {
|
||||||
|
t.Fatal(err, ver)
|
||||||
|
}
|
||||||
|
if opt.Since != "1h" || opt.Host != "https://ntfy.example" || len(opt.Topics) != 2 || opt.Topics[0] != "alerts" {
|
||||||
|
t.Fatalf("%+v", opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpExitsCleanly(t *testing.T) {
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
code := run([]string{"--help"}, &errBuf)
|
||||||
|
if code != 0 || !bytes.Contains(errBuf.Bytes(), []byte("lazyntfy")) {
|
||||||
|
t.Fatalf("code %d\n%s", code, errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRememberedSubsUseCurrentHostAndAuth(t *testing.T) {
|
||||||
|
db, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.SaveTopic(config.Sub{Origin: "https://ntfy.example", Name: "alerts", Display: "Alerts"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SaveTopic(config.Sub{Name: "backups"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := config.Auth{Token: "tk_current"}
|
||||||
|
got := rememberedSubs(db, "https://default.example", auth)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
byName := map[string]config.Sub{}
|
||||||
|
for _, sub := range got {
|
||||||
|
byName[sub.Name] = sub
|
||||||
|
}
|
||||||
|
if sub := byName["alerts"]; sub.Origin != "https://ntfy.example" || sub.Auth.Token != "tk_current" {
|
||||||
|
t.Fatalf("alerts %+v", sub)
|
||||||
|
}
|
||||||
|
if sub := byName["backups"]; sub.Origin != "https://default.example" || sub.Auth.Token != "tk_current" {
|
||||||
|
t.Fatalf("backups %+v", sub)
|
||||||
|
}
|
||||||
|
if rememberedSubs(nil, "https://default.example", auth) != nil {
|
||||||
|
t.Fatal("nil history should yield no topics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewestFromHistory(t *testing.T) {
|
||||||
|
db, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.SaveMessage("https://ntfy.example", store.Message{ID: "a", Topic: "alerts", Time: 100}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SaveMessage("https://ntfy.example", store.Message{ID: "b", Topic: "alerts", Time: 250}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subs := []config.Sub{{Origin: "https://ntfy.example", Name: "alerts"}}
|
||||||
|
got := newestFromHistory(db, subs)
|
||||||
|
if got["https://ntfy.example/alerts"] != 250 {
|
||||||
|
t.Fatalf("newest %+v", got)
|
||||||
|
}
|
||||||
|
if len(newestFromHistory(db, []config.Sub{{Origin: "https://ntfy.example", Name: "empty"}})) != 0 {
|
||||||
|
t.Fatal("topic without history should not be primed")
|
||||||
|
}
|
||||||
|
if newestFromHistory(nil, subs) != nil {
|
||||||
|
t.Fatal("nil history should yield no cursors")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
module lazyntfy
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
charm.land/bubbles/v2 v2.2.1
|
||||||
|
charm.land/bubbletea/v2 v2.1.0
|
||||||
|
charm.land/lipgloss/v2 v2.0.6
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.60.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
|
modernc.org/libc v1.77.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||||
|
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||||
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||||
|
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||||
|
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||||
|
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||||
|
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Package alert extracts a readable title and severity from Grafana and
|
||||||
|
// Alertmanager webhook bodies without hiding the raw payload.
|
||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TitleSeverity returns the list title and a severity/status string.
|
||||||
|
// An ntfy title wins; otherwise Grafana's title or alertname is used.
|
||||||
|
func TitleSeverity(ntfyTitle, body string) (string, string) {
|
||||||
|
title := strings.TrimSpace(ntfyTitle)
|
||||||
|
obj, ok := object(body)
|
||||||
|
sev := ""
|
||||||
|
if ok {
|
||||||
|
labels, _ := obj["commonLabels"].(map[string]any)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(str(obj["title"]))
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(str(labels["alertname"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sev = strings.TrimSpace(str(obj["status"]))
|
||||||
|
if sev == "" {
|
||||||
|
sev = strings.TrimSpace(str(labels["severity"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
title = "(empty)"
|
||||||
|
} else {
|
||||||
|
title = strings.Split(body, "\n")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title, sev
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone picks a color role. An explicit severity wins over ntfy priority.
|
||||||
|
func Tone(sev string, priority int) string {
|
||||||
|
s := strings.ToLower(sev)
|
||||||
|
switch {
|
||||||
|
case s == "firing" || s == "critical" || s == "error" || strings.Contains(s, "critical") || strings.Contains(s, "error"):
|
||||||
|
return "bad"
|
||||||
|
case s == "resolved" || s == "ok" || s == "success" || s == "info" || strings.Contains(s, "resolved"):
|
||||||
|
return "ok"
|
||||||
|
case s != "":
|
||||||
|
return "warn"
|
||||||
|
case priority >= 5:
|
||||||
|
return "bad"
|
||||||
|
case priority == 4:
|
||||||
|
return "warn"
|
||||||
|
case priority > 0 && priority < 3:
|
||||||
|
return "dim"
|
||||||
|
default:
|
||||||
|
return "base"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyView renders an alert webhook as text, or pretty JSON for other objects.
|
||||||
|
// raw forces pretty JSON when the body is JSON.
|
||||||
|
func BodyView(body string, raw bool) string {
|
||||||
|
obj, ok := object(body)
|
||||||
|
if !ok {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
if !raw && isAlert(obj) {
|
||||||
|
return readable(obj)
|
||||||
|
}
|
||||||
|
enc, err := json.MarshalIndent(obj, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return string(enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func object(body string) (map[string]any, bool) {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal([]byte(body), &v); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
obj, ok := v.(map[string]any)
|
||||||
|
return obj, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAlert(obj map[string]any) bool {
|
||||||
|
_, labels := obj["commonLabels"]
|
||||||
|
_, alerts := obj["alerts"]
|
||||||
|
_, ann := obj["commonAnnotations"]
|
||||||
|
return labels || alerts || ann
|
||||||
|
}
|
||||||
|
|
||||||
|
func readable(obj map[string]any) string {
|
||||||
|
labels, _ := obj["commonLabels"].(map[string]any)
|
||||||
|
ann, _ := obj["commonAnnotations"].(map[string]any)
|
||||||
|
var b strings.Builder
|
||||||
|
writeKV(&b, "status", str(obj["status"]))
|
||||||
|
writeKV(&b, "alert", str(labels["alertname"]))
|
||||||
|
writeKV(&b, "severity", str(labels["severity"]))
|
||||||
|
writeKV(&b, "summary", str(ann["summary"]))
|
||||||
|
writeKV(&b, "description", str(ann["description"]))
|
||||||
|
alerts, _ := obj["alerts"].([]any)
|
||||||
|
if len(alerts) > 0 {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString("alerts:\n")
|
||||||
|
for _, item := range alerts {
|
||||||
|
alert, _ := item.(map[string]any)
|
||||||
|
al, _ := alert["labels"].(map[string]any)
|
||||||
|
aa, _ := alert["annotations"].(map[string]any)
|
||||||
|
name := str(al["alertname"])
|
||||||
|
if name == "" {
|
||||||
|
name = "(alert)"
|
||||||
|
}
|
||||||
|
b.WriteString("- [" + str(alert["status"]) + "] " + name + "\n")
|
||||||
|
if s := str(aa["summary"]); s != "" {
|
||||||
|
b.WriteString(" summary: " + s + "\n")
|
||||||
|
}
|
||||||
|
if s := str(aa["description"]); s != "" {
|
||||||
|
b.WriteString(" description: " + s + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKV(b *strings.Builder, k, v string) {
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(k + ": " + v + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const grafana = `{
|
||||||
|
"status": "firing",
|
||||||
|
"title": "DiskFull",
|
||||||
|
"commonLabels": {"alertname": "DiskFull", "severity": "critical"},
|
||||||
|
"commonAnnotations": {"summary": "disk > 90", "description": "host a"},
|
||||||
|
"alerts": [
|
||||||
|
{"status": "firing", "labels": {"alertname": "DiskFull", "severity": "critical"}, "annotations": {"summary": "disk > 90"}}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestTitleSeverityPrefersNtfyTitle(t *testing.T) {
|
||||||
|
title, sev := TitleSeverity("from ntfy", grafana)
|
||||||
|
if title != "from ntfy" || sev != "firing" {
|
||||||
|
t.Fatalf("%q %q", title, sev)
|
||||||
|
}
|
||||||
|
title, sev = TitleSeverity("", grafana)
|
||||||
|
if title != "DiskFull" || sev != "firing" {
|
||||||
|
t.Fatalf("%q %q", title, sev)
|
||||||
|
}
|
||||||
|
title, sev = TitleSeverity("", "plain body")
|
||||||
|
if title != "plain body" || sev != "" {
|
||||||
|
t.Fatalf("%q %q", title, sev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTone(t *testing.T) {
|
||||||
|
if Tone("firing", 3) != "bad" || Tone("resolved", 5) != "ok" || Tone("", 5) != "bad" || Tone("", 4) != "warn" || Tone("", 1) != "dim" {
|
||||||
|
t.Fatal(Tone("firing", 3), Tone("resolved", 5), Tone("", 5), Tone("", 4), Tone("", 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyView(t *testing.T) {
|
||||||
|
view := BodyView(grafana, false)
|
||||||
|
if !containsAll(view, "status: firing", "alert: DiskFull", "summary: disk > 90", "alerts:") {
|
||||||
|
t.Fatalf("%s", view)
|
||||||
|
}
|
||||||
|
raw := BodyView(grafana, true)
|
||||||
|
if !containsAll(raw, "\"commonLabels\"", "DiskFull") {
|
||||||
|
t.Fatalf("%s", raw)
|
||||||
|
}
|
||||||
|
pretty := BodyView(`{"a":1}`, false)
|
||||||
|
if !strings.Contains(pretty, "\"a\": 1") {
|
||||||
|
t.Fatalf("%s", pretty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(s string, parts ...string) bool {
|
||||||
|
for _, p := range parts {
|
||||||
|
if !strings.Contains(s, p) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// Package config loads an ntfy client.yml plus command-line overrides.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPath is the per-user ntfy client config used by the official CLI.
|
||||||
|
func DefaultPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "ntfy", "client.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is sent as a Bearer token or HTTP basic credentials.
|
||||||
|
// A token wins when both are set. Values are never formatted into errors.
|
||||||
|
type Auth struct {
|
||||||
|
Token string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal reports whether two credential sets would produce the same header.
|
||||||
|
func (a Auth) Equal(b Auth) bool {
|
||||||
|
return a.Token == b.Token && a.User == b.User && a.Password == b.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sets Authorization. A token is sent as Bearer; otherwise basic auth is used.
|
||||||
|
func (a Auth) Apply(req *http.Request) {
|
||||||
|
if a.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.User != "" || a.Password != "" {
|
||||||
|
req.SetBasicAuth(a.User, a.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub is one topic the TUI should subscribe to.
|
||||||
|
type Sub struct {
|
||||||
|
Origin string
|
||||||
|
Name string
|
||||||
|
Display string
|
||||||
|
Auth Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label is the sidebar text for this topic.
|
||||||
|
func (s Sub) Label() string {
|
||||||
|
if strings.TrimSpace(s.Display) != "" {
|
||||||
|
return s.Display
|
||||||
|
}
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is the resolved runtime configuration.
|
||||||
|
type App struct {
|
||||||
|
Since string
|
||||||
|
DefaultHost string
|
||||||
|
Auth Auth
|
||||||
|
Subs []Sub
|
||||||
|
StartupStatus string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options are CLI inputs. PathSet reports whether --config was passed.
|
||||||
|
type Options struct {
|
||||||
|
Path string
|
||||||
|
PathSet bool
|
||||||
|
Host string
|
||||||
|
Token string
|
||||||
|
User string
|
||||||
|
Since string
|
||||||
|
Topics []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileConfig struct {
|
||||||
|
DefaultHost string `yaml:"default-host"`
|
||||||
|
DefaultToken string `yaml:"default-token"`
|
||||||
|
DefaultUser string `yaml:"default-user"`
|
||||||
|
DefaultPassword string `yaml:"default-password"`
|
||||||
|
Subscribe []fileSub `yaml:"subscribe"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSub struct {
|
||||||
|
Topic string `yaml:"topic"`
|
||||||
|
Token *string `yaml:"token"`
|
||||||
|
User *string `yaml:"user"`
|
||||||
|
Password *string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads client.yml when it exists and applies CLI overrides.
|
||||||
|
// Positional topics replace the file's subscribe list.
|
||||||
|
func Load(opt Options) (App, error) {
|
||||||
|
path := opt.Path
|
||||||
|
if path == "" {
|
||||||
|
path = DefaultPath()
|
||||||
|
}
|
||||||
|
var file fileConfig
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if err := yaml.Unmarshal(data, &file); err != nil {
|
||||||
|
return App{}, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
case os.IsNotExist(err) && !opt.PathSet:
|
||||||
|
// A missing default file is fine when topics are passed explicitly.
|
||||||
|
case err != nil:
|
||||||
|
return App{}, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := first(opt.Host, file.DefaultHost)
|
||||||
|
auth := Auth{
|
||||||
|
Token: file.DefaultToken,
|
||||||
|
User: file.DefaultUser,
|
||||||
|
Password: file.DefaultPassword,
|
||||||
|
}
|
||||||
|
if opt.Token != "" {
|
||||||
|
auth.Token = opt.Token
|
||||||
|
}
|
||||||
|
if opt.User != "" {
|
||||||
|
auth.User, auth.Password = splitUser(opt.User)
|
||||||
|
auth.Token = ""
|
||||||
|
}
|
||||||
|
if auth.Token != "" {
|
||||||
|
auth.User, auth.Password = "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
since := opt.Since
|
||||||
|
if since == "" {
|
||||||
|
since = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
specs := file.Subscribe
|
||||||
|
if len(opt.Topics) > 0 {
|
||||||
|
specs = make([]fileSub, len(opt.Topics))
|
||||||
|
for i, topic := range opt.Topics {
|
||||||
|
specs[i].Topic = topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(specs) == 0 {
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
return App{}, fmt.Errorf("no topic given and no default-host in %s", path)
|
||||||
|
}
|
||||||
|
return App{Since: since, DefaultHost: host, Auth: auth}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subs := make([]Sub, 0, len(specs))
|
||||||
|
for _, spec := range specs {
|
||||||
|
origin, name, err := Resolve(spec.Topic, host)
|
||||||
|
if err != nil {
|
||||||
|
return App{}, err
|
||||||
|
}
|
||||||
|
subAuth := auth
|
||||||
|
if spec.Token != nil {
|
||||||
|
subAuth = Auth{Token: *spec.Token}
|
||||||
|
}
|
||||||
|
if spec.User != nil {
|
||||||
|
pass := ""
|
||||||
|
if spec.Password != nil {
|
||||||
|
pass = *spec.Password
|
||||||
|
}
|
||||||
|
subAuth = Auth{User: *spec.User, Password: pass}
|
||||||
|
}
|
||||||
|
subs = append(subs, Sub{Origin: origin, Name: name, Auth: subAuth})
|
||||||
|
}
|
||||||
|
|
||||||
|
return App{
|
||||||
|
Since: since,
|
||||||
|
DefaultHost: host,
|
||||||
|
Auth: auth,
|
||||||
|
Subs: subs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSubs appends topics that are not already in base, compared by origin and name.
|
||||||
|
func MergeSubs(base, extra []Sub) []Sub {
|
||||||
|
out := append([]Sub(nil), base...)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, sub := range out {
|
||||||
|
seen[sub.Origin+"\n"+sub.Name] = true
|
||||||
|
}
|
||||||
|
for _, sub := range extra {
|
||||||
|
key := sub.Origin + "\n" + sub.Name
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, sub)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitUser(s string) (string, string) {
|
||||||
|
user, pass, ok := strings.Cut(s, ":")
|
||||||
|
if !ok {
|
||||||
|
return user, ""
|
||||||
|
}
|
||||||
|
return user, pass
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadSubscribeAndTokenOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "client.yml")
|
||||||
|
body := []byte(`
|
||||||
|
default-host: https://ntfy.example
|
||||||
|
default-token: tk_default
|
||||||
|
subscribe:
|
||||||
|
- topic: alerts
|
||||||
|
command: notify-send
|
||||||
|
- topic: public
|
||||||
|
token: ""
|
||||||
|
- topic: other.example/ops
|
||||||
|
user: ada
|
||||||
|
password: secret
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := Load(Options{Path: path, PathSet: true, Since: "1h"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if app.Since != "1h" || len(app.Subs) != 3 {
|
||||||
|
t.Fatalf("app: %+v", app.Subs)
|
||||||
|
}
|
||||||
|
if app.Subs[0].Name != "alerts" || app.Subs[0].Origin != "https://ntfy.example" || app.Subs[0].Auth.Token != "tk_default" {
|
||||||
|
t.Fatalf("alerts: %+v", app.Subs[0])
|
||||||
|
}
|
||||||
|
if app.Subs[1].Auth.Token != "" || app.Subs[1].Auth.User != "" {
|
||||||
|
t.Fatalf("empty token should clear auth: %+v", app.Subs[1].Auth)
|
||||||
|
}
|
||||||
|
if app.Subs[2].Origin != "https://other.example" || app.Subs[2].Name != "ops" || app.Subs[2].Auth.User != "ada" || app.Subs[2].Auth.Password != "secret" {
|
||||||
|
t.Fatalf("per-topic user: %+v", app.Subs[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPositionalTopicsReplaceSubscribe(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "client.yml")
|
||||||
|
body := []byte("default-host: https://ntfy.example\ndefault-token: tk_default\nsubscribe:\n - topic: ignored\n")
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := Load(Options{Path: path, PathSet: true, Topics: []string{"alerts"}, User: "ada:pw"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(app.Subs) != 1 || app.Subs[0].Name != "alerts" || app.Subs[0].Auth.Token != "" || app.Subs[0].Auth.User != "ada" {
|
||||||
|
t.Fatalf("%+v", app.Subs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostWithoutTopicsStartsEmpty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "client.yml")
|
||||||
|
body := []byte("default-host: https://ntfy.example\ndefault-token: tk_default\n")
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app, err := Load(Options{Path: path, PathSet: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if app.DefaultHost != "https://ntfy.example" || len(app.Subs) != 0 || app.Auth.Token != "tk_default" {
|
||||||
|
t.Fatalf("%+v", app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingDefaultFileNeedsTopic(t *testing.T) {
|
||||||
|
_, err := Load(Options{Path: filepath.Join(t.TempDir(), "missing.yml")})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolve turns a short topic, host/topic, or absolute URL into an origin and
|
||||||
|
// a single topic name. defaultHost is used only for short names.
|
||||||
|
func Resolve(topic, defaultHost string) (origin, name string, err error) {
|
||||||
|
topic = strings.TrimSpace(topic)
|
||||||
|
if topic == "" {
|
||||||
|
return "", "", fmt.Errorf("empty topic")
|
||||||
|
}
|
||||||
|
if !strings.Contains(topic, "://") && strings.Contains(topic, "/") {
|
||||||
|
return Resolve("https://"+topic, "")
|
||||||
|
}
|
||||||
|
if strings.Contains(topic, "://") {
|
||||||
|
u, err := url.Parse(topic)
|
||||||
|
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return "", "", fmt.Errorf("invalid topic URL %q", topic)
|
||||||
|
}
|
||||||
|
path := strings.Trim(u.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return "", "", fmt.Errorf("topic URL %q has no topic", topic)
|
||||||
|
}
|
||||||
|
base := u.Scheme + "://" + u.Host
|
||||||
|
if i := strings.LastIndex(path, "/"); i >= 0 {
|
||||||
|
base += "/" + path[:i]
|
||||||
|
path = path[i+1:]
|
||||||
|
}
|
||||||
|
if err := checkName(path); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return base, path, nil
|
||||||
|
}
|
||||||
|
if err := checkName(topic); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
origin, err = normalizeOrigin(defaultHost)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("topic %q: %w", topic, err)
|
||||||
|
}
|
||||||
|
return origin, topic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkName(name string) error {
|
||||||
|
if name == "" || strings.Contains(name, ",") || strings.ContainsAny(name, " \t") {
|
||||||
|
return fmt.Errorf("invalid topic name %q", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOrigin(host string) (string, error) {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("no default-host")
|
||||||
|
}
|
||||||
|
if !strings.Contains(host, "://") {
|
||||||
|
host = "https://" + host
|
||||||
|
}
|
||||||
|
u, err := url.Parse(strings.TrimRight(host, "/"))
|
||||||
|
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return "", fmt.Errorf("invalid host %q", host)
|
||||||
|
}
|
||||||
|
origin := u.Scheme + "://" + u.Host
|
||||||
|
if path := strings.Trim(u.Path, "/"); path != "" {
|
||||||
|
origin += "/" + path
|
||||||
|
}
|
||||||
|
return origin, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolve(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
topic, host, origin, name string
|
||||||
|
}{
|
||||||
|
{"alerts", "https://ntfy.example", "https://ntfy.example", "alerts"},
|
||||||
|
{"alerts", "https://ntfy.example/", "https://ntfy.example", "alerts"},
|
||||||
|
{"https://ntfy.example/alerts", "", "https://ntfy.example", "alerts"},
|
||||||
|
{"ntfy.example/alerts", "", "https://ntfy.example", "alerts"},
|
||||||
|
{"https://ntfy.example/base/alerts", "", "https://ntfy.example/base", "alerts"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
origin, name, err := Resolve(tc.topic, tc.host)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.topic, err)
|
||||||
|
}
|
||||||
|
if origin != tc.origin || name != tc.name {
|
||||||
|
t.Fatalf("%s: got %s %s", tc.topic, origin, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := Resolve("alerts", ""); err == nil {
|
||||||
|
t.Fatal("expected missing host error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Package history stores topics and messages on disk so they survive restarts.
|
||||||
|
// Credentials are not written.
|
||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxPerTopic = 1000
|
||||||
|
|
||||||
|
// DB is the on-disk history.
|
||||||
|
type DB struct {
|
||||||
|
sql *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPath is $XDG_DATA_HOME/lazyntfy/history.db, or ~/.local/share/lazyntfy/history.db.
|
||||||
|
func DefaultPath() string {
|
||||||
|
base := os.Getenv("XDG_DATA_HOME")
|
||||||
|
if base == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "lazyntfy", "history.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open creates the database and its tables.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("empty history path")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
display TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (origin, name)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
origin TEXT NOT NULL DEFAULT '',
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
time INTEGER NOT NULL,
|
||||||
|
expires INTEGER NOT NULL DEFAULT 0,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
click TEXT NOT NULL DEFAULT '',
|
||||||
|
attachment TEXT NOT NULL DEFAULT '',
|
||||||
|
actions TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS messages_topic_time ON messages (topic, time DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := &DB{sql: db}
|
||||||
|
h.pruneExpired()
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the file.
|
||||||
|
func (d *DB) Close() error {
|
||||||
|
if d == nil || d.sql == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.sql.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTopic remembers a topic name. Auth is intentionally omitted.
|
||||||
|
func (d *DB) SaveTopic(sub config.Sub) error {
|
||||||
|
if d == nil || sub.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
INSERT INTO topics (origin, name, display) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (origin, name) DO UPDATE SET display = excluded.display
|
||||||
|
`, sub.Origin, sub.Name, sub.Display)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topics returns every remembered topic, newest names last as stored.
|
||||||
|
func (d *DB) Topics() ([]config.Sub, error) {
|
||||||
|
if d == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := d.sql.Query(`SELECT origin, name, display FROM topics ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []config.Sub
|
||||||
|
for rows.Next() {
|
||||||
|
var sub config.Sub
|
||||||
|
if err := rows.Scan(&sub.Origin, &sub.Name, &sub.Display); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, sub)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMessage inserts or replaces one message and trims the topic to the cap.
|
||||||
|
func (d *DB) SaveMessage(origin string, m store.Message) error {
|
||||||
|
if d == nil || m.ID == "" || m.Topic == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tags, err := json.Marshal(m.Tags)
|
||||||
|
if err != nil {
|
||||||
|
tags = []byte("[]")
|
||||||
|
}
|
||||||
|
attachment := ""
|
||||||
|
if m.Attachment != nil {
|
||||||
|
raw, err := json.Marshal(m.Attachment)
|
||||||
|
if err == nil {
|
||||||
|
attachment = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx, err := d.sql.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO messages (id, origin, topic, time, expires, title, body, tags, priority, click, attachment, actions)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
body = excluded.body,
|
||||||
|
tags = excluded.tags,
|
||||||
|
priority = excluded.priority,
|
||||||
|
click = excluded.click,
|
||||||
|
attachment = excluded.attachment,
|
||||||
|
actions = excluded.actions
|
||||||
|
`, m.ID, origin, m.Topic, m.Time, m.Expires, m.Title, m.Body, string(tags), m.Priority, m.Click, attachment, m.Actions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var kept int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM messages WHERE topic = ?`, m.Topic).Scan(&kept); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if kept > maxPerTopic {
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
DELETE FROM messages WHERE topic = ? AND id NOT IN (
|
||||||
|
SELECT id FROM messages WHERE topic = ? ORDER BY time DESC LIMIT ?
|
||||||
|
)
|
||||||
|
`, m.Topic, m.Topic, maxPerTopic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages returns up to limit messages for a topic, newest first.
|
||||||
|
func (d *DB) Messages(topic string, limit int) ([]store.Message, error) {
|
||||||
|
if d == nil || topic == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = maxPerTopic
|
||||||
|
}
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT id, topic, time, expires, title, body, tags, priority, click, attachment, actions
|
||||||
|
FROM messages WHERE topic = ? ORDER BY time DESC LIMIT ?
|
||||||
|
`, topic, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []store.Message
|
||||||
|
for rows.Next() {
|
||||||
|
var m store.Message
|
||||||
|
var tags, attachment string
|
||||||
|
if err := rows.Scan(&m.ID, &m.Topic, &m.Time, &m.Expires, &m.Title, &m.Body, &tags, &m.Priority, &m.Click, &attachment, &m.Actions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(tags), &m.Tags)
|
||||||
|
if attachment != "" {
|
||||||
|
var a store.Attachment
|
||||||
|
if err := json.Unmarshal([]byte(attachment), &a); err == nil && (a.Name != "" || a.URL != "") {
|
||||||
|
m.Attachment = &a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes one message.
|
||||||
|
func (d *DB) DeleteMessage(id string) error {
|
||||||
|
if d == nil || id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM messages WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTopic removes every message for a topic.
|
||||||
|
func (d *DB) ClearTopic(topic string) error {
|
||||||
|
if d == nil || topic == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM messages WHERE topic = ?`, topic)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState stores a small UI preference, such as the last topic.
|
||||||
|
func (d *DB) SetState(key, value string) error {
|
||||||
|
if d == nil || key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
INSERT INTO state (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
||||||
|
`, key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// State reads a preference. ok is false when it was never set.
|
||||||
|
func (d *DB) State(key string) (string, bool) {
|
||||||
|
if d == nil || key == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
err := d.sql.QueryRow(`SELECT value FROM state WHERE key = ?`, key).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) pruneExpired() {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, _ = d.sql.Exec(`DELETE FROM messages WHERE expires > 0 AND expires < ?`, now)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "history.db")
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SaveTopic(config.Sub{Origin: "https://ntfy.example", Name: "alerts", Display: "Alerts"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msg := store.Message{
|
||||||
|
ID: "m1", Topic: "alerts", Time: 20, Title: "Disk", Body: "full",
|
||||||
|
Tags: []string{"ops"}, Priority: 5, Click: "https://example.com",
|
||||||
|
Attachment: &store.Attachment{Name: "a.png", URL: "https://example.com/a.png", Size: 4},
|
||||||
|
}
|
||||||
|
if err := db.SaveMessage("https://ntfy.example", msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SaveMessage("https://ntfy.example", store.Message{ID: "m0", Topic: "alerts", Time: 10, Body: "older"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SetState("topic", "alerts"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err = Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
topics, err := db.Topics()
|
||||||
|
if err != nil || len(topics) != 1 || topics[0].Display != "Alerts" || topics[0].Auth.Token != "" {
|
||||||
|
t.Fatalf("%v %+v", err, topics)
|
||||||
|
}
|
||||||
|
got, err := db.Messages("alerts", 10)
|
||||||
|
if err != nil || len(got) != 2 || got[0].ID != "m1" || got[0].Tags[0] != "ops" || got[0].Attachment.Name != "a.png" {
|
||||||
|
t.Fatalf("%v %+v", err, got)
|
||||||
|
}
|
||||||
|
if name, ok := db.State("topic"); !ok || name != "alerts" {
|
||||||
|
t.Fatalf("state %q %v", name, ok)
|
||||||
|
}
|
||||||
|
if err := db.DeleteMessage("m0"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ClearTopic("alerts"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
left, err := db.Messages("alerts", 10)
|
||||||
|
if err != nil || len(left) != 0 {
|
||||||
|
t.Fatalf("%v %+v", err, left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneExpiredOnOpen(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "history.db")
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, m := range []store.Message{
|
||||||
|
{ID: "live", Topic: "t", Time: 1},
|
||||||
|
{ID: "gone", Topic: "t", Time: 2, Expires: now - 60},
|
||||||
|
{ID: "later", Topic: "t", Time: 3, Expires: now + 3600},
|
||||||
|
} {
|
||||||
|
if err := db.SaveMessage("o", m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err = Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
got, err := db.Messages("t", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].ID != "later" || got[1].ID != "live" {
|
||||||
|
t.Fatalf("kept %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopicCapKeepsNewest(t *testing.T) {
|
||||||
|
db, err := Open(filepath.Join(t.TempDir(), "history.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for i := 0; i < maxPerTopic+5; i++ {
|
||||||
|
msg := store.Message{ID: fmt.Sprintf("m%d", i), Topic: "t", Time: int64(i)}
|
||||||
|
if err := db.SaveMessage("o", msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := db.Messages("t", maxPerTopic+100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != maxPerTopic {
|
||||||
|
t.Fatalf("kept %d, want %d", len(got), maxPerTopic)
|
||||||
|
}
|
||||||
|
if got[0].ID != fmt.Sprintf("m%d", maxPerTopic+4) {
|
||||||
|
t.Fatalf("newest is %s", got[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountBody struct {
|
||||||
|
SyncTopic string `json:"sync_topic"`
|
||||||
|
Subscriptions []struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
} `json:"subscriptions"`
|
||||||
|
Reservations []struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
} `json:"reservations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type grantBody struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userBody struct {
|
||||||
|
Grants []grantBody `json:"grants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountTopics lists topics visible to this login: account subscriptions,
|
||||||
|
// reserved topics, and ACL grants when the token can read /v1/users.
|
||||||
|
// Wildcard patterns are skipped. Credentials are not included in errors.
|
||||||
|
func (c *Client) AccountTopics(ctx context.Context, host string, auth config.Auth) ([]config.Sub, error) {
|
||||||
|
host = strings.TrimRight(strings.TrimSpace(host), "/")
|
||||||
|
if host == "" || (auth.Token == "" && auth.User == "") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
body, status, err := c.get(ctx, host+"/v1/account", auth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: not authorized to list topics", status)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d from /v1/account", status)
|
||||||
|
}
|
||||||
|
var account accountBody
|
||||||
|
if err := json.Unmarshal(body, &account); err != nil {
|
||||||
|
return nil, fmt.Errorf("account response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subs []config.Sub
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(base, topic, display string) {
|
||||||
|
topic = strings.TrimSpace(topic)
|
||||||
|
if topic == "" || strings.Contains(topic, "*") || topic == account.SyncTopic {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
originHost := host
|
||||||
|
if strings.TrimSpace(base) != "" {
|
||||||
|
originHost = strings.TrimRight(base, "/")
|
||||||
|
}
|
||||||
|
origin, name, err := config.Resolve(topic, originHost)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := origin + "\n" + name
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
subs = append(subs, config.Sub{
|
||||||
|
Origin: origin,
|
||||||
|
Name: name,
|
||||||
|
Display: strings.TrimSpace(display),
|
||||||
|
Auth: auth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range account.Subscriptions {
|
||||||
|
display := ""
|
||||||
|
if sub.DisplayName != nil {
|
||||||
|
display = *sub.DisplayName
|
||||||
|
}
|
||||||
|
add(sub.BaseURL, sub.Topic, display)
|
||||||
|
}
|
||||||
|
for _, res := range account.Reservations {
|
||||||
|
add("", res.Topic, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
usersBody, usersStatus, err := c.get(ctx, host+"/v1/users", auth)
|
||||||
|
if err != nil || usersStatus < 200 || usersStatus >= 300 {
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
for _, grant := range parseGrants(usersBody) {
|
||||||
|
perm := strings.ToLower(grant.Permission)
|
||||||
|
if perm == "deny-all" || perm == "deny" || perm == "none" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add("", grant.Topic, "")
|
||||||
|
}
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGrants(body []byte) []grantBody {
|
||||||
|
var wrapped struct {
|
||||||
|
Users []userBody `json:"users"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Users != nil {
|
||||||
|
return flattenGrants(wrapped.Users)
|
||||||
|
}
|
||||||
|
var list []userBody
|
||||||
|
if err := json.Unmarshal(body, &list); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return flattenGrants(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenGrants(users []userBody) []grantBody {
|
||||||
|
var out []grantBody
|
||||||
|
for _, user := range users {
|
||||||
|
out = append(out, user.Grants...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, endpoint string, auth config.Auth) ([]byte, int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
auth.Apply(req)
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, err
|
||||||
|
}
|
||||||
|
return body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountTopics(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer tk_test" {
|
||||||
|
t.Errorf("missing auth")
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/account":
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"sync_topic":"st_skip",
|
||||||
|
"subscriptions":[
|
||||||
|
{"base_url":"", "topic":"alerts", "display_name":"Alerts"},
|
||||||
|
{"topic":"st_skip"},
|
||||||
|
{"topic":"ops*"}
|
||||||
|
],
|
||||||
|
"reservations":[{"topic":"backups"}]
|
||||||
|
}`))
|
||||||
|
case "/v1/users":
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"username":"ada","grants":[
|
||||||
|
{"topic":"stats","permission":"read-only"},
|
||||||
|
{"topic":"*","permission":"read-write"},
|
||||||
|
{"topic":"secret","permission":"deny-all"}
|
||||||
|
]}
|
||||||
|
]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
subs, err := NewClient().AccountTopics(context.Background(), srv.URL, config.Auth{Token: "tk_test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
got[sub.Name] = sub.Display
|
||||||
|
if sub.Auth.Token != "tk_test" {
|
||||||
|
t.Fatal("auth dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, hasBackups := got["backups"]
|
||||||
|
_, hasStats := got["stats"]
|
||||||
|
if got["alerts"] != "Alerts" || !hasBackups || !hasStats {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got["st_skip"]; ok {
|
||||||
|
t.Fatal("sync topic included")
|
||||||
|
}
|
||||||
|
if _, ok := got["secret"]; ok {
|
||||||
|
t.Fatal("denied topic included")
|
||||||
|
}
|
||||||
|
if _, ok := got["*"]; ok || len(got) != 3 {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountTopicsIgnoresUsersForbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/users" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"reservations":[{"topic":"alerts"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
subs, err := NewClient().AccountTopics(context.Background(), srv.URL, config.Auth{Token: "tk_test"})
|
||||||
|
if err != nil || len(subs) != 1 || subs[0].Name != "alerts" {
|
||||||
|
t.Fatalf("%v %+v", err, subs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const userAgent = "lazyntfy/0.1.0"
|
||||||
|
|
||||||
|
// Client talks to an ntfy server over HTTP.
|
||||||
|
type Client struct {
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient returns a client whose timeout covers the handshake, not the
|
||||||
|
// long-lived response body.
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{HTTP: &http.Client{Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 30 * time.Second,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) http() *http.Client {
|
||||||
|
if c != nil && c.HTTP != nil {
|
||||||
|
return c.HTTP
|
||||||
|
}
|
||||||
|
return http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishRequest is a plain-text ntfy publish.
|
||||||
|
type PublishRequest struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Priority int
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamMeta is returned once response headers have been read.
|
||||||
|
type StreamMeta struct {
|
||||||
|
Truncated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenStream starts a JSON subscription. The caller must close the body.
|
||||||
|
func (c *Client) OpenStream(ctx context.Context, endpoint string, auth config.Auth) (*http.Response, StreamMeta, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, StreamMeta{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "application/x-ndjson")
|
||||||
|
auth.Apply(req)
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, StreamMeta{}, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return nil, StreamMeta{}, httpStatusError(resp)
|
||||||
|
}
|
||||||
|
return resp, StreamMeta{Truncated: resp.Header.Get("X-Messages-Truncated") == "1"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish sends a message to one topic.
|
||||||
|
func (c *Client) Publish(ctx context.Context, origin, name string, auth config.Auth, msg PublishRequest) error {
|
||||||
|
if strings.TrimSpace(msg.Message) == "" {
|
||||||
|
return fmt.Errorf("message is empty")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, PublishURL(origin, name), strings.NewReader(msg.Message))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
if msg.Title != "" {
|
||||||
|
req.Header.Set("Title", msg.Title)
|
||||||
|
}
|
||||||
|
if msg.Priority > 0 {
|
||||||
|
req.Header.Set("Priority", priorityHeader(msg.Priority))
|
||||||
|
}
|
||||||
|
if len(msg.Tags) > 0 {
|
||||||
|
req.Header.Set("Tags", strings.Join(msg.Tags, ","))
|
||||||
|
}
|
||||||
|
auth.Apply(req)
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return httpStatusError(resp)
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpStatusError(resp *http.Response) error {
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return fmt.Errorf("HTTP %d: not authorized for this topic", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||||
|
if msg == "" {
|
||||||
|
return fmt.Errorf("HTTP %s", resp.Status)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("HTTP %s: %s", resp.Status, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorityHeader(p int) string {
|
||||||
|
switch p {
|
||||||
|
case 1:
|
||||||
|
return "min"
|
||||||
|
case 2:
|
||||||
|
return "low"
|
||||||
|
case 3:
|
||||||
|
return "default"
|
||||||
|
case 4:
|
||||||
|
return "high"
|
||||||
|
case 5:
|
||||||
|
return "max"
|
||||||
|
default:
|
||||||
|
return strconv.Itoa(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePriority accepts 1-5 or the ntfy names min, low, default, high, and max.
|
||||||
|
func ParsePriority(s string) (int, error) {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if s == "" {
|
||||||
|
return 3, nil
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "min", "1":
|
||||||
|
return 1, nil
|
||||||
|
case "low", "2":
|
||||||
|
return 2, nil
|
||||||
|
case "default", "def", "3":
|
||||||
|
return 3, nil
|
||||||
|
case "high", "4":
|
||||||
|
return 4, nil
|
||||||
|
case "max", "urgent", "5":
|
||||||
|
return 5, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("priority must be 1-5 or min/low/default/high/max")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTags splits a comma-separated tag list.
|
||||||
|
func ParseTags(s string) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenStreamAndPublish(t *testing.T) {
|
||||||
|
var published string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer tk_test" {
|
||||||
|
t.Errorf("auth header missing")
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if !strings.Contains(r.URL.Path, "/alerts/json") {
|
||||||
|
t.Errorf("path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Messages-Truncated", "1")
|
||||||
|
_, _ = io.WriteString(w, "{\"id\":\"m1\",\"time\":10,\"event\":\"message\",\"topic\":\"alerts\",\"message\":\"hi\"}\n")
|
||||||
|
case http.MethodPost:
|
||||||
|
if r.URL.Path != "/alerts" {
|
||||||
|
t.Errorf("post path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Title") != "Disk" || r.Header.Get("Priority") != "high" || r.Header.Get("Tags") != "a,b" {
|
||||||
|
t.Errorf("headers %v", r.Header)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
published = string(b)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
t.Errorf("method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient()
|
||||||
|
auth := config.Auth{Token: "tk_test"}
|
||||||
|
resp, meta, err := c.OpenStream(context.Background(), srv.URL+"/alerts/json?since=all", auth)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if !meta.Truncated {
|
||||||
|
t.Fatal("expected truncation header")
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := ReadLines(resp.Body, func(ev Event) error {
|
||||||
|
n++
|
||||||
|
if ev.Message != "hi" {
|
||||||
|
t.Fatalf("%+v", ev)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("events %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.Publish(context.Background(), srv.URL, "alerts", auth, PublishRequest{
|
||||||
|
Title: "Disk", Message: "full", Priority: 4, Tags: []string{"a", "b"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if published != "full" {
|
||||||
|
t.Fatalf("body %q", published)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthorizedHidesBody(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = io.WriteString(w, "token tk_supersecret rejected")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
_, _, err := NewClient().OpenStream(context.Background(), srv.URL+"/alerts/json", config.Auth{Token: "tk_test"})
|
||||||
|
if err == nil || strings.Contains(err.Error(), "tk_supersecret") || !strings.Contains(err.Error(), "not authorized") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePriority(t *testing.T) {
|
||||||
|
p, err := ParsePriority("high")
|
||||||
|
if err != nil || p != 4 {
|
||||||
|
t.Fatal(p, err)
|
||||||
|
}
|
||||||
|
if _, err := ParsePriority("nope"); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
tags := ParseTags(" a, b ,,c ")
|
||||||
|
if strings.Join(tags, "|") != "a|b|c" {
|
||||||
|
t.Fatal(tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one NDJSON object from GET /topic/json.
|
||||||
|
type Event struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Expires int64 `json:"expires,omitempty"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Click string `json:"click"`
|
||||||
|
SequenceID string `json:"sequence_id,omitempty"`
|
||||||
|
Attachment *Attachment `json:"attachment,omitempty"`
|
||||||
|
Actions json.RawMessage `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment describes a file linked from a message.
|
||||||
|
type Attachment struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Expires int64 `json:"expires,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLine decodes one NDJSON line. ok is false for blank lines.
|
||||||
|
func ParseLine(line []byte) (Event, bool, error) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
return Event{}, false, nil
|
||||||
|
}
|
||||||
|
var ev Event
|
||||||
|
if err := json.Unmarshal(line, &ev); err != nil {
|
||||||
|
return Event{}, false, err
|
||||||
|
}
|
||||||
|
if ev.Event == "" {
|
||||||
|
return Event{}, false, fmt.Errorf("missing event")
|
||||||
|
}
|
||||||
|
return ev, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLines yields each decoded event until EOF or a read error.
|
||||||
|
// Malformed JSON is reported and skipped so one bad line does not kill the stream.
|
||||||
|
func ReadLines(r io.Reader, yield func(Event) error, onBad func(error)) error {
|
||||||
|
sc := newScanner(r)
|
||||||
|
for sc.Scan() {
|
||||||
|
ev, ok, err := ParseLine(sc.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
if onBad != nil {
|
||||||
|
onBad(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := yield(ev); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sc.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLine(t *testing.T) {
|
||||||
|
ev, ok, err := ParseLine([]byte(`{"id":"abc","time":10,"event":"message","topic":"alerts","message":"hi","priority":5}`))
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatal(err, ok)
|
||||||
|
}
|
||||||
|
if ev.ID != "abc" || ev.Priority != 5 || ev.Message != "hi" {
|
||||||
|
t.Fatalf("%+v", ev)
|
||||||
|
}
|
||||||
|
if _, ok, err := ParseLine([]byte(" ")); err != nil || ok {
|
||||||
|
t.Fatalf("blank: %v %v", err, ok)
|
||||||
|
}
|
||||||
|
if _, _, err := ParseLine([]byte("{")); err == nil {
|
||||||
|
t.Fatal("expected json error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLinesSkipsGarbage(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
var bad int
|
||||||
|
err := ReadLines(strings.NewReader("nope\n{\"id\":\"a\",\"time\":1,\"event\":\"open\",\"topic\":\"t\"}\n"), func(ev Event) error {
|
||||||
|
got = append(got, ev.Event)
|
||||||
|
return nil
|
||||||
|
}, func(error) { bad++ })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bad != 1 || len(got) != 1 || got[0] != "open" {
|
||||||
|
t.Fatalf("bad=%d got=%v", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group is a set of topics that share an origin and credentials and can
|
||||||
|
// therefore share one JSON stream.
|
||||||
|
type Group struct {
|
||||||
|
Origin string
|
||||||
|
Names []string
|
||||||
|
Auth config.Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL is the /json subscription endpoint for this group.
|
||||||
|
func (g Group) URL(since string) string {
|
||||||
|
parts := make([]string, len(g.Names))
|
||||||
|
for i, name := range g.Names {
|
||||||
|
parts[i] = url.PathEscape(name)
|
||||||
|
}
|
||||||
|
endpoint := g.Origin + "/" + strings.Join(parts, ",") + "/json"
|
||||||
|
if since == "" {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("since", since)
|
||||||
|
return endpoint + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishURL is the POST endpoint for one topic.
|
||||||
|
func PublishURL(origin, name string) string {
|
||||||
|
return origin + "/" + url.PathEscape(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupSubs buckets subscriptions that can share a connection.
|
||||||
|
func GroupSubs(subs []config.Sub) []Group {
|
||||||
|
var out []Group
|
||||||
|
index := map[string]int{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
key := sub.Origin + "\n" + sub.Auth.Token + "\n" + sub.Auth.User + "\n" + sub.Auth.Password
|
||||||
|
if i, ok := index[key]; ok {
|
||||||
|
out[i].Names = append(out[i].Names, sub.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index[key] = len(out)
|
||||||
|
out = append(out, Group{Origin: sub.Origin, Names: []string{sub.Name}, Auth: sub.Auth})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroupSubs(t *testing.T) {
|
||||||
|
subs := []config.Sub{
|
||||||
|
{Origin: "https://h", Name: "a", Auth: config.Auth{Token: "t"}},
|
||||||
|
{Origin: "https://h", Name: "b", Auth: config.Auth{Token: "t"}},
|
||||||
|
{Origin: "https://h", Name: "c", Auth: config.Auth{Token: "other"}},
|
||||||
|
}
|
||||||
|
groups := GroupSubs(subs)
|
||||||
|
if len(groups) != 2 || len(groups[0].Names) != 2 {
|
||||||
|
t.Fatalf("%+v", groups)
|
||||||
|
}
|
||||||
|
if groups[0].URL("all") != "https://h/a,b/json?since=all" {
|
||||||
|
t.Fatalf("url %s", groups[0].URL("all"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrimeAvoidsReplayingRestoredHistory(t *testing.T) {
|
||||||
|
gotSince := make(chan string, 1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotSince <- r.URL.Query().Get("since")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mgr := NewManager(NewClient())
|
||||||
|
mgr.Prime(map[string]int64{srv.URL + "/alerts": 1234})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
mgr.Start(ctx, []config.Sub{{
|
||||||
|
Origin: srv.URL,
|
||||||
|
Name: "alerts",
|
||||||
|
Auth: config.Auth{Token: "tk_test"},
|
||||||
|
}}, "all")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case since := <-gotSince:
|
||||||
|
if since != "1234" {
|
||||||
|
t.Fatalf("first since %q", since)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for the first request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerReconnectsWithCursor(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
secondSince := make(chan string, 1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := hits.Add(1)
|
||||||
|
if r.Header.Get("Authorization") != "Bearer tk_test" {
|
||||||
|
t.Errorf("missing auth")
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
if r.URL.Query().Get("since") != "all" {
|
||||||
|
t.Errorf("first since %q", r.URL.Query().Get("since"))
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "{\"id\":\"m1\",\"time\":100,\"event\":\"message\",\"topic\":\"alerts\",\"message\":\"hi\"}\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secondSince <- r.URL.Query().Get("since")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mgr := NewManager(NewClient())
|
||||||
|
mgr.SetBackoff(5*time.Millisecond, 20*time.Millisecond)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
mgr.Start(ctx, []config.Sub{{
|
||||||
|
Origin: srv.URL,
|
||||||
|
Name: "alerts",
|
||||||
|
Auth: config.Auth{Token: "tk_test"},
|
||||||
|
}}, "all")
|
||||||
|
|
||||||
|
deadline := time.After(3 * time.Second)
|
||||||
|
sawMessage := false
|
||||||
|
for !sawMessage {
|
||||||
|
select {
|
||||||
|
case n := <-mgr.Notices():
|
||||||
|
if n.Event != nil && n.Event.ID == "m1" {
|
||||||
|
sawMessage = true
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timed out waiting for the first message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case since := <-secondSince:
|
||||||
|
if since != "100" {
|
||||||
|
t.Fatalf("reconnect since %q", since)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for reconnect, hits=%d", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newScanner(r io.Reader) *bufio.Scanner {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
sc.Buffer(buf, 10*1024*1024)
|
||||||
|
return sc
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Package store keeps an in-memory, newest-first view of ntfy messages.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attachment is a file linked from a message.
|
||||||
|
type Attachment struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Type string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is one delivered ntfy notification.
|
||||||
|
type Message struct {
|
||||||
|
ID string
|
||||||
|
Time int64
|
||||||
|
Expires int64
|
||||||
|
Topic string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
Tags []string
|
||||||
|
Priority int
|
||||||
|
Click string
|
||||||
|
Attachment *Attachment
|
||||||
|
Actions string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is safe for concurrent readers and writers.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
byTopic map[string][]Message
|
||||||
|
index map[string]string
|
||||||
|
order []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an empty store.
|
||||||
|
func New() *Store {
|
||||||
|
return &Store{
|
||||||
|
byTopic: map[string][]Message{},
|
||||||
|
index: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put inserts a message. A repeated id is ignored.
|
||||||
|
func (s *Store) Put(m Message) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if m.ID != "" {
|
||||||
|
if _, ok := s.index[m.ID]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.index[m.ID] = m.Topic
|
||||||
|
}
|
||||||
|
s.ensure(m.Topic)
|
||||||
|
s.byTopic[m.Topic] = insertNewest(s.byTopic[m.Topic], m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the message with id.
|
||||||
|
func (s *Store) Delete(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
topic, ok := s.index[id]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := s.byTopic[topic]
|
||||||
|
for i, m := range list {
|
||||||
|
if m.ID == id {
|
||||||
|
s.byTopic[topic] = append(list[:i], list[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(s.index, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes every message for a topic.
|
||||||
|
func (s *Store) Clear(topic string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, m := range s.byTopic[topic] {
|
||||||
|
if m.ID != "" {
|
||||||
|
delete(s.index, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.byTopic[topic] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a copy of one topic, newest first.
|
||||||
|
func (s *Store) List(topic string) []Message {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
src := s.byTopic[topic]
|
||||||
|
out := make([]Message, len(src))
|
||||||
|
copy(out, src)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topics returns topic names in the order they were first seen.
|
||||||
|
func (s *Store) Topics() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]string, len(s.order))
|
||||||
|
copy(out, s.order)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ensure(topic string) {
|
||||||
|
if _, ok := s.byTopic[topic]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.byTopic[topic] = nil
|
||||||
|
s.order = append(s.order, topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertNewest(list []Message, m Message) []Message {
|
||||||
|
i := 0
|
||||||
|
for i < len(list) && list[i].Time > m.Time {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
list = append(list, Message{})
|
||||||
|
copy(list[i+1:], list[i:])
|
||||||
|
list[i] = m
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match reports whether needle is contained in the message's visible fields.
|
||||||
|
func Match(m Message, title, needle string) bool {
|
||||||
|
needle = strings.ToLower(strings.TrimSpace(needle))
|
||||||
|
if needle == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hay := strings.ToLower(strings.Join([]string{
|
||||||
|
title,
|
||||||
|
m.Title,
|
||||||
|
m.Body,
|
||||||
|
m.ID,
|
||||||
|
strings.Join(m.Tags, " "),
|
||||||
|
}, " "))
|
||||||
|
return strings.Contains(hay, needle)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPutDedupeOrderDeleteClear(t *testing.T) {
|
||||||
|
s := New()
|
||||||
|
s.Put(Message{ID: "a", Time: 1, Topic: "alerts", Body: "old"})
|
||||||
|
s.Put(Message{ID: "b", Time: 2, Topic: "alerts", Body: "new"})
|
||||||
|
s.Put(Message{ID: "b", Time: 3, Topic: "alerts", Body: "dup"})
|
||||||
|
s.Put(Message{ID: "c", Time: 2, Topic: "backups", Body: "bak"})
|
||||||
|
list := s.List("alerts")
|
||||||
|
if len(list) != 2 || list[0].ID != "b" || list[1].ID != "a" {
|
||||||
|
t.Fatalf("%+v", list)
|
||||||
|
}
|
||||||
|
if got := s.Topics(); len(got) != 2 || got[0] != "alerts" || got[1] != "backups" {
|
||||||
|
t.Fatalf("topics %v", got)
|
||||||
|
}
|
||||||
|
s.Delete("a")
|
||||||
|
if len(s.List("alerts")) != 1 {
|
||||||
|
t.Fatal(s.List("alerts"))
|
||||||
|
}
|
||||||
|
s.Clear("alerts")
|
||||||
|
if len(s.List("alerts")) != 0 || len(s.List("backups")) != 1 {
|
||||||
|
t.Fatal("clear")
|
||||||
|
}
|
||||||
|
if !Match(Message{Body: "Hello Disk"}, "DiskFull", "disk") {
|
||||||
|
t.Fatal("match")
|
||||||
|
}
|
||||||
|
if Match(Message{Body: "Hello"}, "x", "nope") {
|
||||||
|
t.Fatal("unexpected match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package textutil wraps and clips plain text for a fixed terminal width.
|
||||||
|
package textutil
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Clip shortens s to at most n runes, replacing tabs and newlines with spaces.
|
||||||
|
func Clip(s string, n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s = strings.ReplaceAll(strings.ReplaceAll(s, "\t", " "), "\n", " ")
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap breaks s into lines no wider than width runes, preserving blank lines.
|
||||||
|
func Wrap(s string, width int) []string {
|
||||||
|
if width <= 1 {
|
||||||
|
if s == "" {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
return []string{s}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, para := range strings.Split(s, "\n") {
|
||||||
|
if para == "" {
|
||||||
|
out = append(out, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := ""
|
||||||
|
for _, word := range strings.Split(para, " ") {
|
||||||
|
for len([]rune(word)) > width {
|
||||||
|
if line != "" {
|
||||||
|
out = append(out, line)
|
||||||
|
line = ""
|
||||||
|
}
|
||||||
|
r := []rune(word)
|
||||||
|
out = append(out, string(r[:width]))
|
||||||
|
word = string(r[width:])
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case line == "":
|
||||||
|
line = word
|
||||||
|
case len([]rune(line))+1+len([]rune(word)) <= width:
|
||||||
|
line += " " + word
|
||||||
|
default:
|
||||||
|
out = append(out, line)
|
||||||
|
line = word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad pads s with spaces on the right until it is n runes wide.
|
||||||
|
func Pad(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) >= n {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
return string(r) + strings.Repeat(" ", n-len(r))
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package textutil
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClip(t *testing.T) {
|
||||||
|
if got := Clip("abcdef", 4); got != "abc…" {
|
||||||
|
t.Fatalf("clip: %q", got)
|
||||||
|
}
|
||||||
|
if got := Clip("ab", 4); got != "ab" {
|
||||||
|
t.Fatalf("short: %q", got)
|
||||||
|
}
|
||||||
|
if got := Clip("a\nb", 5); got != "a b" {
|
||||||
|
t.Fatalf("newline: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrap(t *testing.T) {
|
||||||
|
got := Wrap("one two three", 7)
|
||||||
|
want := []string{"one two", "three"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("wrap %#v", got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("line %d: %q", i, got[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blank := Wrap("a\n\nb", 10)
|
||||||
|
if len(blank) != 3 || blank[1] != "" {
|
||||||
|
t.Fatalf("blank lines: %#v", blank)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Package theme is the Catppuccin palette used by lazyntfy.
|
||||||
|
// Dark terminals get Mocha and light terminals get Latte, so the chrome
|
||||||
|
// matches a Catppuccin terminal theme.
|
||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"charm.land/lipgloss/v2/compat"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pair(dark, light string) color.Color {
|
||||||
|
return compat.AdaptiveColor{Dark: lipgloss.Color(dark), Light: lipgloss.Color(light)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Mocha / Latte.
|
||||||
|
mauve = pair("#cba6f7", "#8839ef")
|
||||||
|
red = pair("#f38ba8", "#d20f39")
|
||||||
|
yellow = pair("#f9e2af", "#df8e1d")
|
||||||
|
green = pair("#a6e3a1", "#40a02b")
|
||||||
|
text = pair("#cdd6f4", "#4c4f69")
|
||||||
|
overlay = pair("#6c7086", "#9ca0b0")
|
||||||
|
surface = pair("#313244", "#ccd0da")
|
||||||
|
base = pair("#1e1e2e", "#eff1f5")
|
||||||
|
crust = pair("#11111b", "#dce0e8")
|
||||||
|
|
||||||
|
Title = lipgloss.NewStyle().Bold(true).Foreground(mauve)
|
||||||
|
Accent = lipgloss.NewStyle().Foreground(mauve)
|
||||||
|
Text = lipgloss.NewStyle().Foreground(text)
|
||||||
|
Dim = lipgloss.NewStyle().Foreground(overlay)
|
||||||
|
OK = lipgloss.NewStyle().Foreground(green)
|
||||||
|
Warn = lipgloss.NewStyle().Foreground(yellow)
|
||||||
|
Bad = lipgloss.NewStyle().Bold(true).Foreground(red)
|
||||||
|
Badge = lipgloss.NewStyle().Bold(true).Foreground(crust).Background(mauve)
|
||||||
|
Key = lipgloss.NewStyle().Bold(true).Foreground(mauve).Background(surface)
|
||||||
|
Filter = lipgloss.NewStyle().Foreground(crust).Background(yellow)
|
||||||
|
Selected = lipgloss.NewStyle().Bold(true).Foreground(base).Background(mauve)
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type publishDone struct{ err error }
|
||||||
|
type openDone struct{ err error }
|
||||||
|
|
||||||
|
func publishCmd(client *ntfy.Client, sub config.Sub, req ntfy.PublishRequest) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return publishDone{err: client.Publish(ctx, sub.Origin, sub.Name, sub.Auth, req)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCmd(raw string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return openDone{err: openURL(raw)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openURL(raw string) error {
|
||||||
|
if err := validateURL(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exec.Command("xdg-open", raw).Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
// Package tui is the lazyntfy terminal UI.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/alert"
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/history"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
"lazyntfy/internal/textutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type focus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusTopics focus = iota
|
||||||
|
focusMessages
|
||||||
|
focusDetail
|
||||||
|
)
|
||||||
|
|
||||||
|
type mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeNormal mode = iota
|
||||||
|
modeFilter
|
||||||
|
modePublish
|
||||||
|
modeAdd
|
||||||
|
modeHelp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the Bubble Tea root model.
|
||||||
|
type Model struct {
|
||||||
|
app config.App
|
||||||
|
subs []config.Sub
|
||||||
|
notices <-chan ntfy.Notice
|
||||||
|
client *ntfy.Client
|
||||||
|
mgr *ntfy.Manager
|
||||||
|
hist *history.DB
|
||||||
|
store *store.Store
|
||||||
|
selected map[string]string
|
||||||
|
up map[string]bool
|
||||||
|
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
focus focus
|
||||||
|
mode mode
|
||||||
|
|
||||||
|
topicIdx int
|
||||||
|
detailTop int
|
||||||
|
filter string
|
||||||
|
raw bool
|
||||||
|
status string
|
||||||
|
restored int
|
||||||
|
savedTopic string
|
||||||
|
filterInput textinput.Model
|
||||||
|
addInput textinput.Model
|
||||||
|
form [4]textinput.Model
|
||||||
|
formIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a model. notices, client, mgr, and hist may be nil in tests.
|
||||||
|
func New(app config.App, notices <-chan ntfy.Notice, client *ntfy.Client, mgr *ntfy.Manager, hist *history.DB) Model {
|
||||||
|
status := "connecting"
|
||||||
|
focusPane := focusMessages
|
||||||
|
if app.StartupStatus != "" {
|
||||||
|
status = app.StartupStatus
|
||||||
|
}
|
||||||
|
if len(app.Subs) == 0 {
|
||||||
|
focusPane = focusTopics
|
||||||
|
if app.StartupStatus == "" {
|
||||||
|
status = "no topics yet — press a to add one"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := Model{
|
||||||
|
app: app,
|
||||||
|
subs: append([]config.Sub(nil), app.Subs...),
|
||||||
|
notices: notices,
|
||||||
|
client: client,
|
||||||
|
mgr: mgr,
|
||||||
|
hist: hist,
|
||||||
|
store: store.New(),
|
||||||
|
selected: map[string]string{},
|
||||||
|
up: map[string]bool{},
|
||||||
|
status: status,
|
||||||
|
focus: focusPane,
|
||||||
|
}
|
||||||
|
m.filterInput = newField("", "filter")
|
||||||
|
m.filterInput.CharLimit = 200
|
||||||
|
m.addInput = newField("", "topic or host/topic")
|
||||||
|
m.addInput.CharLimit = 200
|
||||||
|
placeholders := [4]string{"optional title", "3", "comma separated", "message"}
|
||||||
|
for i, placeholder := range placeholders {
|
||||||
|
m.form[i] = newField("", placeholder)
|
||||||
|
m.form[i].CharLimit = 256
|
||||||
|
}
|
||||||
|
m.form[3].CharLimit = 4000
|
||||||
|
m.resizeInputs()
|
||||||
|
if hist != nil {
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
msgs, err := hist.Messages(sub.Name, 0)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
m.store.Put(msg)
|
||||||
|
m.restored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name, ok := hist.State("topic"); ok && name != "" {
|
||||||
|
m.savedTopic = name
|
||||||
|
m = m.focusTopic(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func newField(prompt, placeholder string) textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Prompt = prompt
|
||||||
|
ti.Placeholder = placeholder
|
||||||
|
ti.SetWidth(48)
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init starts the subscription listener.
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
if m.notices == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return waitNotice(m.notices)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitNotice(ch <-chan ntfy.Notice) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
n, ok := <-ch
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles input, stream notices, and publish results.
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.resizeInputs()
|
||||||
|
return m, nil
|
||||||
|
case ntfy.Notice:
|
||||||
|
m = m.apply(msg)
|
||||||
|
return m, waitNotice(m.notices)
|
||||||
|
case publishDone:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.status = msg.err.Error()
|
||||||
|
} else {
|
||||||
|
m.status = "published"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case openDone:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.status = msg.err.Error()
|
||||||
|
} else {
|
||||||
|
m.status = "opened"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
if keyString(msg) == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
switch m.mode {
|
||||||
|
case modeFilter:
|
||||||
|
return m.updateFilter(msg)
|
||||||
|
case modePublish:
|
||||||
|
return m.updatePublish(msg)
|
||||||
|
case modeAdd:
|
||||||
|
return m.updateAdd(msg)
|
||||||
|
case modeHelp:
|
||||||
|
m.mode = modeNormal
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
return m.updateNormal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateNormal(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "j", "down":
|
||||||
|
return m.move(1), nil
|
||||||
|
case "k", "up":
|
||||||
|
return m.move(-1), nil
|
||||||
|
case "ctrl+d", "pgdown":
|
||||||
|
return m.move(m.page()), nil
|
||||||
|
case "ctrl+u", "pgup":
|
||||||
|
return m.move(-m.page()), nil
|
||||||
|
case "g", "home":
|
||||||
|
return m.moveEdge(true), nil
|
||||||
|
case "G", "end", "shift+g":
|
||||||
|
return m.moveEdge(false), nil
|
||||||
|
case "tab":
|
||||||
|
m.focus = (m.focus + 1) % 3
|
||||||
|
return m, nil
|
||||||
|
case "shift+tab":
|
||||||
|
m.focus = (m.focus + 2) % 3
|
||||||
|
return m, nil
|
||||||
|
case "1":
|
||||||
|
m.focus = focusTopics
|
||||||
|
return m, nil
|
||||||
|
case "2":
|
||||||
|
m.focus = focusMessages
|
||||||
|
return m, nil
|
||||||
|
case "3":
|
||||||
|
m.focus = focusDetail
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
m.focus = focusMessages
|
||||||
|
case focusMessages:
|
||||||
|
m.focus = focusDetail
|
||||||
|
default:
|
||||||
|
m.focus = focusMessages
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "/":
|
||||||
|
m.mode = modeFilter
|
||||||
|
m.filterInput.SetValue(m.filter)
|
||||||
|
return m, m.filterInput.Focus()
|
||||||
|
case "esc":
|
||||||
|
if m.filter != "" {
|
||||||
|
m.filter = ""
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
return m.ensureSelection(), nil
|
||||||
|
}
|
||||||
|
if m.focus == focusDetail {
|
||||||
|
m.focus = focusMessages
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "r":
|
||||||
|
if m.mgr != nil {
|
||||||
|
m.mgr.Reload()
|
||||||
|
}
|
||||||
|
m.status = "reconnecting"
|
||||||
|
return m, nil
|
||||||
|
case "p":
|
||||||
|
return m.beginPublish()
|
||||||
|
case "a":
|
||||||
|
return m.beginAdd()
|
||||||
|
case "v":
|
||||||
|
m.raw = !m.raw
|
||||||
|
m.detailTop = 0
|
||||||
|
return m, nil
|
||||||
|
case "?":
|
||||||
|
m.mode = modeHelp
|
||||||
|
return m, nil
|
||||||
|
case "o":
|
||||||
|
return m.openClick()
|
||||||
|
default:
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateFilter(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "enter":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.filter = m.filterInput.Value()
|
||||||
|
m.filterInput.Blur()
|
||||||
|
return m.ensureSelection(), nil
|
||||||
|
case "esc":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.filter = ""
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
m.filterInput.Blur()
|
||||||
|
return m.ensureSelection(), nil
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||||
|
m.filter = m.filterInput.Value()
|
||||||
|
return m.ensureSelection(), cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateAdd(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "enter":
|
||||||
|
return m.submitAdd(), nil
|
||||||
|
case "esc":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.addInput.Blur()
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.addInput, cmd = m.addInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updatePublish(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch keyString(msg) {
|
||||||
|
case "esc":
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.blurForm()
|
||||||
|
return m, nil
|
||||||
|
case "tab", "down":
|
||||||
|
return m.formStep(1)
|
||||||
|
case "shift+tab", "up":
|
||||||
|
return m.formStep(-1)
|
||||||
|
case "enter":
|
||||||
|
if m.formIdx < len(m.form)-1 {
|
||||||
|
return m.formStep(1)
|
||||||
|
}
|
||||||
|
return m.submitPublish()
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.form[m.formIdx], cmd = m.form[m.formIdx].Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) formStep(delta int) (tea.Model, tea.Cmd) {
|
||||||
|
m.form[m.formIdx].Blur()
|
||||||
|
m.formIdx += delta
|
||||||
|
if m.formIdx < 0 {
|
||||||
|
m.formIdx = len(m.form) - 1
|
||||||
|
}
|
||||||
|
if m.formIdx >= len(m.form) {
|
||||||
|
m.formIdx = 0
|
||||||
|
}
|
||||||
|
return m, m.form[m.formIdx].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) beginPublish() (Model, tea.Cmd) {
|
||||||
|
if _, ok := m.currentSub(); !ok {
|
||||||
|
m.status = "no topic to publish to"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.client == nil {
|
||||||
|
m.status = "publish unavailable"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = modePublish
|
||||||
|
m.formIdx = 0
|
||||||
|
for i := range m.form {
|
||||||
|
m.form[i].SetValue("")
|
||||||
|
m.form[i].Blur()
|
||||||
|
}
|
||||||
|
return m, m.form[0].Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) submitPublish() (Model, tea.Cmd) {
|
||||||
|
sub, ok := m.currentSub()
|
||||||
|
if !ok {
|
||||||
|
m.status = "no topic to publish to"
|
||||||
|
m.mode = modeNormal
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
priority, err := ntfy.ParsePriority(m.form[1].Value())
|
||||||
|
if err != nil {
|
||||||
|
m.status = err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(m.form[3].Value())
|
||||||
|
if message == "" {
|
||||||
|
m.status = "message is empty"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
req := ntfy.PublishRequest{
|
||||||
|
Title: strings.TrimSpace(m.form[0].Value()),
|
||||||
|
Priority: priority,
|
||||||
|
Tags: ntfy.ParseTags(m.form[2].Value()),
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.blurForm()
|
||||||
|
m.status = "publishing"
|
||||||
|
return m, publishCmd(m.client, sub, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) beginAdd() (Model, tea.Cmd) {
|
||||||
|
m.mode = modeAdd
|
||||||
|
m.addInput.SetValue("")
|
||||||
|
return m, m.addInput.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) submitAdd() Model {
|
||||||
|
m.addInput.Blur()
|
||||||
|
m.mode = modeNormal
|
||||||
|
origin, name, err := config.Resolve(m.addInput.Value(), m.app.DefaultHost)
|
||||||
|
if err != nil {
|
||||||
|
m.status = err.Error()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Origin == origin && sub.Name == name {
|
||||||
|
m.status = name + " already open"
|
||||||
|
return m.focusTopic(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub := config.Sub{Origin: origin, Name: name, Auth: m.app.Auth}
|
||||||
|
m.subs = append(m.subs, sub)
|
||||||
|
if m.mgr != nil {
|
||||||
|
m.mgr.Add(sub)
|
||||||
|
}
|
||||||
|
if m.hist != nil {
|
||||||
|
_ = m.hist.SaveTopic(config.Sub{Origin: origin, Name: name})
|
||||||
|
}
|
||||||
|
m.status = "subscribed to " + name
|
||||||
|
return m.focusTopic(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) focusTopic(name string) Model {
|
||||||
|
names := m.topicNames()
|
||||||
|
for i, n := range names {
|
||||||
|
if n == name {
|
||||||
|
m.topicIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.detailTop = 0
|
||||||
|
return m.ensureSelection().persistTopic()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) blurForm() {
|
||||||
|
for i := range m.form {
|
||||||
|
m.form[i].Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) openClick() (Model, tea.Cmd) {
|
||||||
|
msg, ok := m.selectedMessage()
|
||||||
|
if !ok || msg.Click == "" {
|
||||||
|
m.status = "no click URL"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, openCmd(msg.Click)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) apply(n ntfy.Notice) Model {
|
||||||
|
if n.UpSet {
|
||||||
|
for _, topic := range n.Topics {
|
||||||
|
for _, part := range strings.Split(topic, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
m.up[part] = n.Up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Status != "" {
|
||||||
|
m.status = n.Status
|
||||||
|
}
|
||||||
|
if n.Event != nil {
|
||||||
|
switch n.Event.Event {
|
||||||
|
case "message":
|
||||||
|
msg := toMessage(*n.Event)
|
||||||
|
m.store.Put(msg)
|
||||||
|
m.remember(msg)
|
||||||
|
case "message_delete":
|
||||||
|
m.store.Delete(n.Event.ID)
|
||||||
|
if m.hist != nil {
|
||||||
|
_ = m.hist.DeleteMessage(n.Event.ID)
|
||||||
|
}
|
||||||
|
case "message_clear":
|
||||||
|
m.store.Clear(n.Event.Topic)
|
||||||
|
if m.hist != nil {
|
||||||
|
_ = m.hist.ClearTopic(n.Event.Topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.ensureSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMessage(ev ntfy.Event) store.Message {
|
||||||
|
msg := store.Message{
|
||||||
|
ID: ev.ID,
|
||||||
|
Time: ev.Time,
|
||||||
|
Expires: ev.Expires,
|
||||||
|
Topic: ev.Topic,
|
||||||
|
Title: ev.Title,
|
||||||
|
Body: ev.Message,
|
||||||
|
Tags: ev.Tags,
|
||||||
|
Priority: ev.Priority,
|
||||||
|
Click: ev.Click,
|
||||||
|
}
|
||||||
|
if ev.Attachment != nil {
|
||||||
|
msg.Attachment = &store.Attachment{
|
||||||
|
Name: ev.Attachment.Name,
|
||||||
|
URL: ev.Attachment.URL,
|
||||||
|
Type: ev.Attachment.Type,
|
||||||
|
Size: ev.Attachment.Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ev.Actions) > 0 {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := json.Indent(&buf, ev.Actions, "", " "); err == nil {
|
||||||
|
msg.Actions = buf.String()
|
||||||
|
} else {
|
||||||
|
msg.Actions = string(ev.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) originFor(topic string) string {
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == topic {
|
||||||
|
return sub.Origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) remember(msg store.Message) {
|
||||||
|
if m.hist == nil || msg.ID == "" || msg.Topic == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
display := ""
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == msg.Topic {
|
||||||
|
display = sub.Display
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = m.hist.SaveTopic(config.Sub{Origin: m.originFor(msg.Topic), Name: msg.Topic, Display: display})
|
||||||
|
_ = m.hist.SaveMessage(m.originFor(msg.Topic), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) persistTopic() Model {
|
||||||
|
if m.hist == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
name := m.currentName()
|
||||||
|
if name == "" || name == m.savedTopic {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if err := m.hist.SetState("topic", name); err == nil {
|
||||||
|
m.savedTopic = name
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) move(delta int) Model {
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
m.topicIdx += delta
|
||||||
|
m.clampTopic()
|
||||||
|
m.detailTop = 0
|
||||||
|
return m.ensureSelection().persistTopic()
|
||||||
|
case focusDetail:
|
||||||
|
m.detailTop += delta
|
||||||
|
return m.clampDetail()
|
||||||
|
default:
|
||||||
|
msgs := m.filtered()
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
name := m.currentName()
|
||||||
|
idx := indexOf(msgs, m.selected[name]) + delta
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx >= len(msgs) {
|
||||||
|
idx = len(msgs) - 1
|
||||||
|
}
|
||||||
|
m.selected[name] = msgs[idx].ID
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) moveEdge(top bool) Model {
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
if top {
|
||||||
|
m.topicIdx = 0
|
||||||
|
} else {
|
||||||
|
m.topicIdx = len(m.topicNames()) - 1
|
||||||
|
}
|
||||||
|
m.clampTopic()
|
||||||
|
m.detailTop = 0
|
||||||
|
return m.ensureSelection().persistTopic()
|
||||||
|
case focusDetail:
|
||||||
|
if top {
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.detailTop = 1 << 30
|
||||||
|
return m.clampDetail()
|
||||||
|
default:
|
||||||
|
msgs := m.filtered()
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
idx := 0
|
||||||
|
if !top {
|
||||||
|
idx = len(msgs) - 1
|
||||||
|
}
|
||||||
|
m.selected[m.currentName()] = msgs[idx].ID
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) page() int {
|
||||||
|
n := m.bodyRows() - 1
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) resizeInputs() {
|
||||||
|
w := m.width / 2
|
||||||
|
if w < 20 {
|
||||||
|
w = 20
|
||||||
|
}
|
||||||
|
if w > 80 {
|
||||||
|
w = 80
|
||||||
|
}
|
||||||
|
m.filterInput.SetWidth(w)
|
||||||
|
m.addInput.SetWidth(w)
|
||||||
|
for i := range m.form {
|
||||||
|
m.form[i].SetWidth(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyString(msg tea.KeyPressMsg) string {
|
||||||
|
if s := msg.String(); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return msg.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(unix int64) string {
|
||||||
|
if unix == 0 {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return time.Unix(unix, 0).Local().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatShort(unix int64) string {
|
||||||
|
if unix == 0 {
|
||||||
|
return " ? "
|
||||||
|
}
|
||||||
|
return time.Unix(unix, 0).Local().Format("01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func priLabel(p int) string {
|
||||||
|
switch p {
|
||||||
|
case 1:
|
||||||
|
return "min"
|
||||||
|
case 2:
|
||||||
|
return "low"
|
||||||
|
case 3:
|
||||||
|
return "def"
|
||||||
|
case 4:
|
||||||
|
return "high"
|
||||||
|
case 5:
|
||||||
|
return "max"
|
||||||
|
default:
|
||||||
|
return "·"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(msgs []store.Message, id string) int {
|
||||||
|
for i, msg := range msgs {
|
||||||
|
if msg.ID == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) topicNames() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var names []string
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == "" || seen[sub.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sub.Name] = true
|
||||||
|
names = append(names, sub.Name)
|
||||||
|
}
|
||||||
|
for _, topic := range m.store.Topics() {
|
||||||
|
if topic == "" || seen[topic] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[topic] = true
|
||||||
|
names = append(names, topic)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) clampTopic() Model {
|
||||||
|
n := len(m.topicNames())
|
||||||
|
if n == 0 {
|
||||||
|
m.topicIdx = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if m.topicIdx < 0 {
|
||||||
|
m.topicIdx = 0
|
||||||
|
}
|
||||||
|
if m.topicIdx >= n {
|
||||||
|
m.topicIdx = n - 1
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentName() string {
|
||||||
|
names := m.topicNames()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m = m.clampTopic()
|
||||||
|
return names[m.topicIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentSub() (config.Sub, bool) {
|
||||||
|
name := m.currentName()
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == name {
|
||||||
|
return sub, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.Sub{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) filtered() []store.Message {
|
||||||
|
var out []store.Message
|
||||||
|
for _, msg := range m.store.List(m.currentName()) {
|
||||||
|
title, _ := alert.TitleSeverity(msg.Title, msg.Body)
|
||||||
|
if store.Match(msg, title, m.filter) {
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) ensureSelection() Model {
|
||||||
|
msgs := m.filtered()
|
||||||
|
name := m.currentName()
|
||||||
|
if name == "" || len(msgs) == 0 {
|
||||||
|
delete(m.selected, name)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
id := m.selected[name]
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg.ID == id {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.selected[name] = msgs[0].ID
|
||||||
|
m.detailTop = 0
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedMessage() (store.Message, bool) {
|
||||||
|
id := m.selected[m.currentName()]
|
||||||
|
if id == "" {
|
||||||
|
return store.Message{}, false
|
||||||
|
}
|
||||||
|
for _, msg := range m.filtered() {
|
||||||
|
if msg.ID == id {
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Message{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) bodyRows() int {
|
||||||
|
n := m.height - 4
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) topicLabel(name string) string {
|
||||||
|
for _, sub := range m.subs {
|
||||||
|
if sub.Name == name {
|
||||||
|
return sub.Label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) clampDetail() Model {
|
||||||
|
_, _, _, _, _, dw := m.layout()
|
||||||
|
width := dw - 1
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
visible := m.bodyRows()
|
||||||
|
if visible < 1 {
|
||||||
|
visible = 1
|
||||||
|
}
|
||||||
|
maxTop := len(m.detailBlocks(width)) - visible
|
||||||
|
if maxTop < 0 {
|
||||||
|
maxTop = 0
|
||||||
|
}
|
||||||
|
if m.detailTop < 0 {
|
||||||
|
m.detailTop = 0
|
||||||
|
}
|
||||||
|
if m.detailTop > maxTop {
|
||||||
|
m.detailTop = maxTop
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatListTime(unix int64) string {
|
||||||
|
if unix == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
t := time.Unix(unix, 0).Local()
|
||||||
|
now := time.Now()
|
||||||
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
|
return t.Format("15:04")
|
||||||
|
}
|
||||||
|
if t.Year() == now.Year() {
|
||||||
|
return t.Format("Jan 02")
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapText(s string, width int) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
return textutil.Wrap(s, width)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/config"
|
||||||
|
"lazyntfy/internal/history"
|
||||||
|
"lazyntfy/internal/ntfy"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sample() Model {
|
||||||
|
m := New(config.App{
|
||||||
|
DefaultHost: "https://ntfy.example",
|
||||||
|
Auth: config.Auth{Token: "tk_test"},
|
||||||
|
Subs: []config.Sub{{Origin: "https://ntfy.example", Name: "alerts", Auth: config.Auth{Token: "tk_test"}}},
|
||||||
|
}, nil, ntfy.NewClient(), nil, nil)
|
||||||
|
m.width = 120
|
||||||
|
m.height = 30
|
||||||
|
m.store.Put(store.Message{ID: "new", Time: 200, Topic: "alerts", Title: "DiskFull", Body: `{"status":"firing","commonLabels":{"alertname":"DiskFull","severity":"critical"}}`})
|
||||||
|
m.store.Put(store.Message{ID: "old", Time: 100, Topic: "alerts", Body: "backup finished"})
|
||||||
|
m = m.ensureSelection()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewShowsNewestAlert(t *testing.T) {
|
||||||
|
view := sample().render()
|
||||||
|
if !strings.Contains(view, "lazyntfy") || !strings.Contains(view, "DiskFull") || !strings.Contains(view, "firing") {
|
||||||
|
t.Fatalf("%s", view)
|
||||||
|
}
|
||||||
|
lines := strings.Split(view, "\n")
|
||||||
|
if len(lines) != 30 {
|
||||||
|
t.Fatalf("height %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveKeepsSelectionByID(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
if m.selected["alerts"] != "new" {
|
||||||
|
t.Fatalf("selected %s", m.selected["alerts"])
|
||||||
|
}
|
||||||
|
m = m.move(1)
|
||||||
|
if m.selected["alerts"] != "old" {
|
||||||
|
t.Fatalf("after down %s", m.selected["alerts"])
|
||||||
|
}
|
||||||
|
next, cmd := m.Update(tea.KeyPressMsg{Text: "q", Code: 'q'})
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected quit")
|
||||||
|
}
|
||||||
|
if next.(Model).selected["alerts"] != "old" {
|
||||||
|
t.Fatal("quit changed selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterAndDelete(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
m.filter = "backup"
|
||||||
|
m = m.ensureSelection()
|
||||||
|
if m.selected["alerts"] != "old" {
|
||||||
|
t.Fatalf("filter selected %s", m.selected["alerts"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.render(), "backup finished") {
|
||||||
|
t.Fatal(m.render())
|
||||||
|
}
|
||||||
|
m = m.apply(ntfy.Notice{Event: &ntfy.Event{ID: "old", Event: "message_delete", Topic: "alerts"}})
|
||||||
|
if len(m.store.List("alerts")) != 1 {
|
||||||
|
t.Fatal("delete")
|
||||||
|
}
|
||||||
|
m = m.apply(ntfy.Notice{Event: &ntfy.Event{Event: "message_clear", Topic: "alerts"}})
|
||||||
|
if len(m.store.List("alerts")) != 0 {
|
||||||
|
t.Fatal("clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishRequiresMessage(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
m.mode = modePublish
|
||||||
|
m.formIdx = 3
|
||||||
|
got, _ := m.updatePublish(tea.KeyPressMsg{Text: "enter", Code: tea.KeyEnter})
|
||||||
|
if got.(Model).status != "message is empty" {
|
||||||
|
t.Fatalf("status %q", got.(Model).status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTopicUsesDefaultHost(t *testing.T) {
|
||||||
|
m := sample()
|
||||||
|
m.addInput.SetValue("backups")
|
||||||
|
m = m.submitAdd()
|
||||||
|
if len(m.subs) != 2 || m.subs[1].Name != "backups" || m.subs[1].Origin != "https://ntfy.example" {
|
||||||
|
t.Fatalf("%+v", m.subs)
|
||||||
|
}
|
||||||
|
if m.subs[1].Auth.Token != "tk_test" {
|
||||||
|
t.Fatal("auth not copied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsNonHTTPClick(t *testing.T) {
|
||||||
|
if err := validateURL("file:///etc/passwd"); err == nil {
|
||||||
|
t.Fatal("expected reject")
|
||||||
|
}
|
||||||
|
if err := validateURL("https://example.com/a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoresMessagesAndTopicFromHistory(t *testing.T) {
|
||||||
|
db, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.SaveMessage("https://ntfy.example", store.Message{
|
||||||
|
ID: "h1", Topic: "alerts", Time: 50, Title: "from disk",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.SetState("topic", "alerts"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := New(config.App{
|
||||||
|
DefaultHost: "https://ntfy.example",
|
||||||
|
Subs: []config.Sub{{Origin: "https://ntfy.example", Name: "alerts"}},
|
||||||
|
}, nil, ntfy.NewClient(), nil, db)
|
||||||
|
if m.restored != 1 || len(m.store.List("alerts")) != 1 {
|
||||||
|
t.Fatalf("restored %d messages %d", m.restored, len(m.store.List("alerts")))
|
||||||
|
}
|
||||||
|
if m.savedTopic != "alerts" || m.currentName() != "alerts" {
|
||||||
|
t.Fatalf("saved %q current %q", m.savedTopic, m.currentName())
|
||||||
|
}
|
||||||
|
m.width, m.height = 120, 30
|
||||||
|
if !strings.Contains(m.render(), "from disk") {
|
||||||
|
t.Fatalf("restored message missing from view:\n%s", m.render())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return fmt.Errorf("only http(s) links can be opened")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"lazyntfy/internal/alert"
|
||||||
|
"lazyntfy/internal/store"
|
||||||
|
"lazyntfy/internal/textutil"
|
||||||
|
"lazyntfy/internal/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
text string
|
||||||
|
style lipgloss.Style
|
||||||
|
raw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// View draws the alternate-screen interface.
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
v := tea.NewView(m.render())
|
||||||
|
v.AltScreen = true
|
||||||
|
v.WindowTitle = "lazyntfy"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) render() string {
|
||||||
|
if m.width < 40 || m.height < 8 {
|
||||||
|
return "terminal too small\n"
|
||||||
|
}
|
||||||
|
switch m.mode {
|
||||||
|
case modeHelp:
|
||||||
|
return m.fit(m.modal("help", m.helpRows()))
|
||||||
|
case modePublish:
|
||||||
|
return m.fit(m.modal("publish", m.publishRows()))
|
||||||
|
case modeAdd:
|
||||||
|
return m.fit(m.modal("add topic", m.addRows()))
|
||||||
|
default:
|
||||||
|
return m.fit(m.renderPanes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) fit(body string) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(body, "\n"), "\n")
|
||||||
|
for len(lines) < m.height {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
if len(lines) > m.height {
|
||||||
|
if m.height >= 2 {
|
||||||
|
tail := lines[len(lines)-1]
|
||||||
|
lines = append(lines[:m.height-1], tail)
|
||||||
|
} else {
|
||||||
|
lines = lines[:m.height]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderPanes() string {
|
||||||
|
panelH := m.height - 2
|
||||||
|
showT, showM, showD, tw, mw, dw := m.layout()
|
||||||
|
var parts []string
|
||||||
|
if showT {
|
||||||
|
parts = append(parts, box("topics", m.focus == focusTopics, tw, panelH, m.topicRows(tw-2)))
|
||||||
|
}
|
||||||
|
if showM {
|
||||||
|
parts = append(parts, box("messages", m.focus == focusMessages, mw, panelH, m.messageRows(mw-2)))
|
||||||
|
}
|
||||||
|
if showD {
|
||||||
|
parts = append(parts, box(m.detailTitle(), m.focus == focusDetail, dw, panelH, m.detailRows(dw-2)))
|
||||||
|
}
|
||||||
|
gapped := make([]string, 0, len(parts)*2-1)
|
||||||
|
for i, part := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
gapped = append(gapped, gapColumn(panelH))
|
||||||
|
}
|
||||||
|
gapped = append(gapped, part)
|
||||||
|
}
|
||||||
|
mid := lipgloss.JoinHorizontal(lipgloss.Top, gapped...)
|
||||||
|
return m.headerLine() + "\n" + mid + "\n" + m.footerLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) headerLine() string {
|
||||||
|
left := badgeStyle.Render(" lazyntfy ")
|
||||||
|
if name := m.currentName(); name != "" {
|
||||||
|
left += " " + lipgloss.NewStyle().Bold(true).Render(m.topicLabel(name))
|
||||||
|
left += dimStyle.Render(fmt.Sprintf(" %d/%d", len(m.filtered()), len(m.store.List(name))))
|
||||||
|
if m.restored > 0 {
|
||||||
|
left += dimStyle.Render(fmt.Sprintf(" history %d", m.restored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.filter != "" {
|
||||||
|
left += " " + filterStyle.Render(" /"+m.filter+" ")
|
||||||
|
}
|
||||||
|
right := m.statusStyle().Render(statusMark(m.status) + m.status + " ")
|
||||||
|
return spread(left, right, m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusMark(status string) string {
|
||||||
|
s := strings.ToLower(status)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "connected") || strings.HasPrefix(s, "published"):
|
||||||
|
return "● "
|
||||||
|
case strings.Contains(s, "disconnect") || strings.Contains(s, "retry") || strings.Contains(s, "not authorized"):
|
||||||
|
return "○ "
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) footerLine() string {
|
||||||
|
if m.mode == modeFilter {
|
||||||
|
return spread(filterStyle.Render(" filter ")+" "+m.filterInput.View(), dimStyle.Render("enter apply esc clear "), m.width)
|
||||||
|
}
|
||||||
|
var keys [][2]string
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
keys = [][2]string{{"j/k", "select"}, {"enter", "open"}, {"a", "add"}, {"1/2/3", "pane"}, {"?", "help"}, {"q", "quit"}}
|
||||||
|
case focusDetail:
|
||||||
|
keys = [][2]string{{"j/k", "scroll"}, {"v", "raw"}, {"o", "open link"}, {"esc", "back"}, {"?", "help"}, {"q", "quit"}}
|
||||||
|
default:
|
||||||
|
keys = [][2]string{{"j/k", "move"}, {"enter", "detail"}, {"/", "filter"}, {"p", "publish"}, {"r", "reload"}, {"q", "quit"}}
|
||||||
|
}
|
||||||
|
return spread(chips(keys), dimStyle.Render("? help "), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chips(keys [][2]string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, key := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(" ")
|
||||||
|
}
|
||||||
|
b.WriteString(keyStyle.Render(" " + key[0] + " "))
|
||||||
|
b.WriteString(dimStyle.Render(" " + key[1]))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) layout() (showT, showM, showD bool, tw, mw, dw int) {
|
||||||
|
panels := 1
|
||||||
|
switch {
|
||||||
|
case m.width >= 100:
|
||||||
|
showT, showM, showD = true, true, true
|
||||||
|
panels = 3
|
||||||
|
tw = 28
|
||||||
|
case m.width >= 72:
|
||||||
|
showT = true
|
||||||
|
panels = 2
|
||||||
|
tw = 24
|
||||||
|
if m.focus == focusDetail {
|
||||||
|
showD = true
|
||||||
|
} else {
|
||||||
|
showM = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
switch m.focus {
|
||||||
|
case focusTopics:
|
||||||
|
showT = true
|
||||||
|
case focusDetail:
|
||||||
|
showD = true
|
||||||
|
default:
|
||||||
|
showM = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest := m.width - (panels - 1)
|
||||||
|
if showT && panels > 1 {
|
||||||
|
if tw > rest-24 {
|
||||||
|
tw = 20
|
||||||
|
}
|
||||||
|
rest -= tw
|
||||||
|
} else if showT {
|
||||||
|
tw = rest
|
||||||
|
rest = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case showM && showD:
|
||||||
|
mw = rest * 42 / 100
|
||||||
|
if mw < 24 {
|
||||||
|
mw = 24
|
||||||
|
}
|
||||||
|
dw = rest - mw
|
||||||
|
if dw < 16 {
|
||||||
|
dw = 16
|
||||||
|
mw = rest - dw
|
||||||
|
}
|
||||||
|
case showM:
|
||||||
|
mw = rest
|
||||||
|
case showD:
|
||||||
|
dw = rest
|
||||||
|
}
|
||||||
|
if tw < 0 {
|
||||||
|
tw = 0
|
||||||
|
}
|
||||||
|
if mw < 0 {
|
||||||
|
mw = 0
|
||||||
|
}
|
||||||
|
if dw < 0 {
|
||||||
|
dw = 0
|
||||||
|
}
|
||||||
|
return showT, showM, showD, tw, mw, dw
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) topicRows(width int) []row {
|
||||||
|
names := m.topicNames()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return []row{
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " No topics yet", style: dimStyle},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " Press a to add one", style: accentStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows := make([]row, 0, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
mark := "○"
|
||||||
|
markStyle := dimStyle
|
||||||
|
if m.up[name] {
|
||||||
|
mark = "●"
|
||||||
|
markStyle = okStyle
|
||||||
|
}
|
||||||
|
count := ""
|
||||||
|
if n := len(m.store.List(name)); n > 0 {
|
||||||
|
count = fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
label := m.topicLabel(name)
|
||||||
|
if i == m.topicIdx {
|
||||||
|
rows = append(rows, row{
|
||||||
|
text: splitLine(" "+mark+" "+label, count, width),
|
||||||
|
style: selectedStyle,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
left := " " + markStyle.Render(mark) + " " + theme.Text.Render(label)
|
||||||
|
right := dimStyle.Render(count)
|
||||||
|
rows = append(rows, row{text: splitStyled(left, right, width), raw: true})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) messageRows(width int) []row {
|
||||||
|
if m.currentName() == "" {
|
||||||
|
return []row{
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " Topics from your account", style: lipgloss.NewStyle().Bold(true)},
|
||||||
|
{text: " show up in the list.", style: dimStyle},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " a add a topic", style: accentStyle},
|
||||||
|
{text: " ? keyboard help", style: dimStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs := m.filtered()
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
hint := " Waiting for messages"
|
||||||
|
if m.filter != "" {
|
||||||
|
hint = " Nothing matches /" + m.filter
|
||||||
|
}
|
||||||
|
return []row{{text: hint, style: dimStyle}}
|
||||||
|
}
|
||||||
|
body := m.bodyRows()
|
||||||
|
selected := indexOf(msgs, m.selected[m.currentName()])
|
||||||
|
start := 0
|
||||||
|
if selected >= body {
|
||||||
|
start = selected - body + 1
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
for i := start; i < len(msgs) && len(rows) < body; i++ {
|
||||||
|
rows = append(rows, messageRow(msgs[i], width, msgs[i].ID == m.selected[m.currentName()]))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageRow(msg store.Message, width int, selected bool) row {
|
||||||
|
title, sev := alert.TitleSeverity(msg.Title, msg.Body)
|
||||||
|
when := textutil.Pad(formatListTime(msg.Time), 6)
|
||||||
|
badge := sev
|
||||||
|
if badge == "" {
|
||||||
|
badge = priLabel(msg.Priority)
|
||||||
|
}
|
||||||
|
badge = textutil.Pad(textutil.Clip(badge, 8), 8)
|
||||||
|
room := width - 1 - 6 - 1 - 8 - 1
|
||||||
|
if room < 8 {
|
||||||
|
room = 8
|
||||||
|
}
|
||||||
|
title = textutil.Clip(title, room)
|
||||||
|
plain := " " + when + " " + badge + " " + title
|
||||||
|
if selected {
|
||||||
|
return row{text: plain, style: selectedStyle}
|
||||||
|
}
|
||||||
|
colored := " " + dimStyle.Render(when) + " " + toneStyle(alert.Tone(sev, msg.Priority)).Render(badge) + " " + theme.Text.Render(title)
|
||||||
|
return row{text: colored, raw: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailTitle() string {
|
||||||
|
n := len(m.detailBlocks(40))
|
||||||
|
visible := m.bodyRows()
|
||||||
|
maxTop := n - visible
|
||||||
|
if maxTop < 1 {
|
||||||
|
return "detail"
|
||||||
|
}
|
||||||
|
top := m.detailTop
|
||||||
|
if top > maxTop {
|
||||||
|
top = maxTop
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("detail %d/%d", top+1, maxTop+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailRows(width int) []row {
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
blocks := m.detailBlocks(width)
|
||||||
|
visible := m.bodyRows()
|
||||||
|
maxTop := len(blocks) - visible
|
||||||
|
if maxTop < 0 {
|
||||||
|
maxTop = 0
|
||||||
|
}
|
||||||
|
top := m.detailTop
|
||||||
|
if top > maxTop {
|
||||||
|
top = maxTop
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
return []row{{text: " Nothing selected", style: dimStyle}}
|
||||||
|
}
|
||||||
|
if top >= len(blocks) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
end := top + visible
|
||||||
|
if end > len(blocks) {
|
||||||
|
end = len(blocks)
|
||||||
|
}
|
||||||
|
return blocks[top:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailBlocks(width int) []row {
|
||||||
|
msg, ok := m.selectedMessage()
|
||||||
|
if !ok {
|
||||||
|
return []row{
|
||||||
|
{text: " Nothing selected", style: dimStyle},
|
||||||
|
{text: " Move through the list and press enter", style: dimStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
title, sev := alert.TitleSeverity(msg.Title, msg.Body)
|
||||||
|
var rows []row
|
||||||
|
if sev != "" {
|
||||||
|
rows = append(rows, row{text: " " + strings.ToUpper(sev), style: toneStyle(alert.Tone(sev, msg.Priority)).Bold(true)})
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: " " + title, style: lipgloss.NewStyle().Bold(true)})
|
||||||
|
rows = append(rows, row{text: " " + formatTime(msg.Time) + " " + msg.ID, style: dimStyle})
|
||||||
|
var meta []string
|
||||||
|
if msg.Priority > 0 {
|
||||||
|
meta = append(meta, priLabel(msg.Priority))
|
||||||
|
}
|
||||||
|
if len(msg.Tags) > 0 {
|
||||||
|
meta = append(meta, strings.Join(msg.Tags, " "))
|
||||||
|
}
|
||||||
|
if len(meta) > 0 {
|
||||||
|
rows = append(rows, row{text: " " + strings.Join(meta, " · "), style: dimStyle})
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: "", style: lipgloss.NewStyle()})
|
||||||
|
for _, line := range wrapText(alert.BodyView(msg.Body, m.raw), width-2) {
|
||||||
|
rows = append(rows, row{text: " " + line, style: lipgloss.NewStyle()})
|
||||||
|
}
|
||||||
|
if msg.Click != "" {
|
||||||
|
rows = append(rows,
|
||||||
|
row{text: "", style: lipgloss.NewStyle()},
|
||||||
|
row{text: " link", style: dimStyle},
|
||||||
|
row{text: " " + msg.Click, style: accentStyle},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if msg.Attachment != nil {
|
||||||
|
rows = append(rows,
|
||||||
|
row{text: "", style: lipgloss.NewStyle()},
|
||||||
|
row{text: " attachment", style: dimStyle},
|
||||||
|
row{text: fmt.Sprintf(" %s (%d bytes)", msg.Attachment.Name, msg.Attachment.Size), style: lipgloss.NewStyle()},
|
||||||
|
)
|
||||||
|
if msg.Attachment.URL != "" {
|
||||||
|
rows = append(rows, row{text: " " + msg.Attachment.URL, style: dimStyle})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg.Expires > 0 {
|
||||||
|
rows = append(rows, row{text: " expires " + formatTime(msg.Expires), style: dimStyle})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) helpRows() []row {
|
||||||
|
pairs := [][2]string{
|
||||||
|
{"j / k", "move in the focused pane"},
|
||||||
|
{"g / G", "first and last"},
|
||||||
|
{"enter", "topics → messages → detail"},
|
||||||
|
{"esc", "back from detail, or clear filter"},
|
||||||
|
{"1 2 3", "jump to a pane"},
|
||||||
|
{"/", "filter title, body, tags, id"},
|
||||||
|
{"a", "add a topic and remember it"},
|
||||||
|
{"p", "publish to the focused topic"},
|
||||||
|
{"r", "reconnect"},
|
||||||
|
{"v", "toggle raw JSON"},
|
||||||
|
{"o", "open the click link"},
|
||||||
|
{"?", "close this help"},
|
||||||
|
{"q", "quit"},
|
||||||
|
}
|
||||||
|
rows := []row{{text: " Keys follow the focused pane.", style: dimStyle}, {text: "", style: lipgloss.NewStyle()}}
|
||||||
|
for _, pair := range pairs {
|
||||||
|
rows = append(rows, row{
|
||||||
|
text: splitLine(" "+pair[0], pair[1], 52),
|
||||||
|
style: lipgloss.NewStyle(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) publishRows() []row {
|
||||||
|
sub, _ := m.currentSub()
|
||||||
|
labels := []string{"Title", "Priority", "Tags", "Message"}
|
||||||
|
rows := []row{{text: " to " + sub.Label(), style: dimStyle}, {text: "", style: lipgloss.NewStyle()}}
|
||||||
|
for i, label := range labels {
|
||||||
|
style := dimStyle
|
||||||
|
if i == m.formIdx {
|
||||||
|
style = accentStyle
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: " " + label, style: style})
|
||||||
|
rows = append(rows, row{text: " " + m.form[i].View(), raw: true})
|
||||||
|
rows = append(rows, row{text: "", style: lipgloss.NewStyle()})
|
||||||
|
}
|
||||||
|
rows = append(rows, row{text: " enter sends tab moves esc cancels", style: dimStyle})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) addRows() []row {
|
||||||
|
return []row{
|
||||||
|
{text: " Short names use your default host.", style: dimStyle},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " Topic", style: accentStyle},
|
||||||
|
{text: " " + m.addInput.View(), raw: true},
|
||||||
|
{text: "", style: lipgloss.NewStyle()},
|
||||||
|
{text: " enter subscribes esc cancels", style: dimStyle},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) modal(title string, rows []row) string {
|
||||||
|
width := m.width - 8
|
||||||
|
if width > 68 {
|
||||||
|
width = 68
|
||||||
|
}
|
||||||
|
if width < 36 {
|
||||||
|
width = m.width
|
||||||
|
}
|
||||||
|
if width > m.width {
|
||||||
|
width = m.width
|
||||||
|
}
|
||||||
|
height := len(rows) + 2
|
||||||
|
if height > m.height-2 {
|
||||||
|
height = m.height - 2
|
||||||
|
}
|
||||||
|
if height < 5 {
|
||||||
|
height = 5
|
||||||
|
}
|
||||||
|
card := box(title, true, width, height, rows)
|
||||||
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) statusStyle() lipgloss.Style {
|
||||||
|
s := strings.ToLower(m.status)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "not authorized") || strings.Contains(s, "http 4") || strings.Contains(s, "http 5"):
|
||||||
|
return badStyle
|
||||||
|
case strings.Contains(s, "disconnect") || strings.Contains(s, "no topic") || strings.Contains(s, "retry"):
|
||||||
|
return warnStyle
|
||||||
|
case strings.Contains(s, "connected") || strings.HasPrefix(s, "published"):
|
||||||
|
return okStyle
|
||||||
|
default:
|
||||||
|
return dimStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func box(title string, active bool, width, height int, rows []row) string {
|
||||||
|
if width < 2 || height < 2 {
|
||||||
|
return strings.Repeat("\n", max(0, height-1))
|
||||||
|
}
|
||||||
|
innerW := width - 2
|
||||||
|
innerH := height - 2
|
||||||
|
border := dimStyle
|
||||||
|
if active {
|
||||||
|
border = accentStyle
|
||||||
|
}
|
||||||
|
label := " " + title + " "
|
||||||
|
if len([]rune(label)) > innerW {
|
||||||
|
label = textutil.Clip(title, innerW)
|
||||||
|
}
|
||||||
|
pad := innerW - len([]rune(label))
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
left := 1
|
||||||
|
if pad == 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
right := pad - left
|
||||||
|
top := border.Render("╭"+strings.Repeat("─", left)) + titleStyle.Render(label) + border.Render(strings.Repeat("─", right)+"╮")
|
||||||
|
lines := []string{top}
|
||||||
|
for i := 0; i < innerH; i++ {
|
||||||
|
var painted string
|
||||||
|
if i < len(rows) && rows[i].raw {
|
||||||
|
painted = fitWidth(rows[i].text, innerW)
|
||||||
|
} else {
|
||||||
|
text := ""
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
if i < len(rows) {
|
||||||
|
text = rows[i].text
|
||||||
|
style = rows[i].style
|
||||||
|
}
|
||||||
|
painted = style.Render(textutil.Pad(textutil.Clip(text, innerW), innerW))
|
||||||
|
}
|
||||||
|
lines = append(lines, border.Render("│")+painted+border.Render("│"))
|
||||||
|
}
|
||||||
|
lines = append(lines, border.Render("╰"+strings.Repeat("─", innerW)+"╯"))
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func gapColumn(height int) string {
|
||||||
|
if height < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := make([]string, height)
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = " "
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func spread(left, right string, width int) string {
|
||||||
|
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
|
if gap < 1 {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLine(left, right string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
gap := width - len([]rune(left)) - len([]rune(right))
|
||||||
|
if gap < 1 {
|
||||||
|
return textutil.Clip(left+" "+right, width)
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitStyled(left, right string, width int) string {
|
||||||
|
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
|
if gap < 1 {
|
||||||
|
return fitWidth(left, width)
|
||||||
|
}
|
||||||
|
return left + strings.Repeat(" ", gap) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitWidth(s string, width int) string {
|
||||||
|
w := lipgloss.Width(s)
|
||||||
|
if w > width && width > 0 {
|
||||||
|
// Styled input lines are already width-limited by the text field.
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if gap := width - w; gap > 0 {
|
||||||
|
return s + strings.Repeat(" ", gap)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func toneStyle(tone string) lipgloss.Style {
|
||||||
|
switch tone {
|
||||||
|
case "bad":
|
||||||
|
return badStyle
|
||||||
|
case "ok":
|
||||||
|
return okStyle
|
||||||
|
case "warn":
|
||||||
|
return warnStyle
|
||||||
|
case "dim":
|
||||||
|
return dimStyle
|
||||||
|
default:
|
||||||
|
return lipgloss.NewStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleStyle = theme.Title
|
||||||
|
accentStyle = theme.Accent
|
||||||
|
badgeStyle = theme.Badge
|
||||||
|
keyStyle = theme.Key
|
||||||
|
filterStyle = theme.Filter
|
||||||
|
dimStyle = theme.Dim
|
||||||
|
okStyle = theme.OK
|
||||||
|
warnStyle = theme.Warn
|
||||||
|
badStyle = theme.Bad
|
||||||
|
selectedStyle = theme.Selected
|
||||||
|
)
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
.\" lazyntfy(1) - a lazygit-style terminal client for ntfy
|
||||||
|
.TH LAZYNTFY 1 "2026-10-11" "lazyntfy 0.1.0" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
lazyntfy \- a lazygit-style terminal client for ntfy
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B lazyntfy
|
||||||
|
.RI [ options ]
|
||||||
|
.RI [ topic ...]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B lazyntfy
|
||||||
|
is a terminal user interface for the
|
||||||
|
.BR ntfy (1)
|
||||||
|
pub\-sub notification service, laid out like
|
||||||
|
.BR lazygit (1):
|
||||||
|
topics in the left pane, messages in the middle pane, and the selected
|
||||||
|
message in the right pane.
|
||||||
|
.PP
|
||||||
|
It speaks the ntfy HTTP API directly, subscribing with
|
||||||
|
.B GET
|
||||||
|
.IR topic /json
|
||||||
|
and publishing with
|
||||||
|
.B POST
|
||||||
|
.IR topic .
|
||||||
|
When no topic is given on the command line it asks the server for the
|
||||||
|
account subscriptions, reserved topics, and ACL grants, then adds any
|
||||||
|
topics remembered from previous sessions. Short topic names are expanded
|
||||||
|
with
|
||||||
|
.IR default\-host .
|
||||||
|
.PP
|
||||||
|
Messages from Grafana and Alertmanager webhooks are summarized (status,
|
||||||
|
alert name, severity, and summary); the raw JSON is available with the
|
||||||
|
.B v
|
||||||
|
key. A message that is deleted or cleared on the server is removed from
|
||||||
|
the list as well.
|
||||||
|
.PP
|
||||||
|
When the connection drops,
|
||||||
|
.B lazyntfy
|
||||||
|
reconnects and sets the stream's
|
||||||
|
.B since
|
||||||
|
parameter to the newest event time it has already seen, so no message is
|
||||||
|
delivered twice.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
.BI \-\-config " path"
|
||||||
|
Read the ntfy client configuration from
|
||||||
|
.IR path
|
||||||
|
instead of
|
||||||
|
.IR ~/.config/ntfy/client.yml .
|
||||||
|
.TP
|
||||||
|
.BI \-\-host " url"
|
||||||
|
Override
|
||||||
|
.I default\-host
|
||||||
|
from the configuration file.
|
||||||
|
.TP
|
||||||
|
.BI \-\-token " token"
|
||||||
|
Use
|
||||||
|
.I token
|
||||||
|
as a Bearer credential, overriding the configuration file.
|
||||||
|
.TP
|
||||||
|
.BI \-\-user " user[:password]"
|
||||||
|
Use HTTP basic authentication, overriding
|
||||||
|
.I default\-user
|
||||||
|
and
|
||||||
|
.I default\-password .
|
||||||
|
A token and a user are mutually exclusive; the last one given wins.
|
||||||
|
.TP
|
||||||
|
.BI \-\-since " value"
|
||||||
|
Replay window to request when a stream is opened. The value is
|
||||||
|
.B all
|
||||||
|
(the default), a duration such as
|
||||||
|
.BR 1h ,
|
||||||
|
a Unix timestamp, or a message id.
|
||||||
|
.TP
|
||||||
|
.B \-\-version
|
||||||
|
Print the version and exit.
|
||||||
|
.TP
|
||||||
|
.B \-\-help
|
||||||
|
Print a short usage summary and exit.
|
||||||
|
.SH KEYS
|
||||||
|
Keys act on the focused pane. Use
|
||||||
|
.B tab
|
||||||
|
or
|
||||||
|
.BR 1 ,
|
||||||
|
.BR 2 ,
|
||||||
|
.B 3
|
||||||
|
to change panes.
|
||||||
|
.TP
|
||||||
|
.B j ", " k ", " \(dqup\(dq ", " \(dqdown\(dq
|
||||||
|
Move within the focused pane..TP
|
||||||
|
.B g ", " G
|
||||||
|
Jump to the first or last item.
|
||||||
|
.TP
|
||||||
|
.B ctrl\-u ", " ctrl\-d
|
||||||
|
Page up or down.
|
||||||
|
.TP
|
||||||
|
.B enter
|
||||||
|
Descend: topics to messages to detail.
|
||||||
|
.TP
|
||||||
|
.B esc
|
||||||
|
Leave the detail pane, or clear an active filter.
|
||||||
|
.TP
|
||||||
|
.B /
|
||||||
|
Filter by title, body, tags, and message id.
|
||||||
|
.TP
|
||||||
|
.B r
|
||||||
|
Reconnect the streams.
|
||||||
|
.TP
|
||||||
|
.B p
|
||||||
|
Publish a message to the focused topic.
|
||||||
|
.TP
|
||||||
|
.B a
|
||||||
|
Subscribe to another topic for this session and remember it.
|
||||||
|
.TP
|
||||||
|
.B v
|
||||||
|
Toggle the raw JSON view of the selected message.
|
||||||
|
.TP
|
||||||
|
.B o
|
||||||
|
Open the selected message's click URL in the default browser.
|
||||||
|
.TP
|
||||||
|
.B ?
|
||||||
|
Show the in\-app help.
|
||||||
|
.TP
|
||||||
|
.B q
|
||||||
|
Quit.
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.I ~/.config/ntfy/client.yml
|
||||||
|
The ntfy client configuration read at startup. It supplies
|
||||||
|
.IR default\-host ,
|
||||||
|
.IR default\-token " / " default\-user " / " default\-password ,
|
||||||
|
and the
|
||||||
|
.I subscribe
|
||||||
|
list. Credentials are sent in the
|
||||||
|
.B Authorization
|
||||||
|
header and are never printed or written back.
|
||||||
|
.TP
|
||||||
|
.IR $XDG_DATA_HOME /lazyntfy/history.db
|
||||||
|
The SQLite database that remembers topics and messages between runs.
|
||||||
|
When
|
||||||
|
.B XDG_DATA_HOME
|
||||||
|
is unset it falls back to
|
||||||
|
.IR ~/.local/share/lazyntfy/history.db .
|
||||||
|
Credentials are never stored. History is best effort: if the file cannot
|
||||||
|
be opened the client still runs, it simply does not remember the session.
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.B XDG_DATA_HOME
|
||||||
|
Base directory for the history database.
|
||||||
|
.SH EXIT STATUS
|
||||||
|
.TP
|
||||||
|
.B 0
|
||||||
|
Success, including
|
||||||
|
.B \-\-help
|
||||||
|
and
|
||||||
|
.BR \-\-version .
|
||||||
|
.TP
|
||||||
|
.B 1
|
||||||
|
The terminal interface failed.
|
||||||
|
.TP
|
||||||
|
.B 2
|
||||||
|
Invalid arguments or configuration.
|
||||||
|
.SH SEE ALSO
|
||||||
|
.BR ntfy (1),
|
||||||
|
.BR curl (1)
|
||||||
|
.PP
|
||||||
|
The ntfy HTTP API:
|
||||||
|
.IR https://docs.ntfy.sh/publish/ .
|
||||||
|
.SH LICENSE
|
||||||
|
GNU General Public License, version 3 or later.
|
||||||
Reference in New Issue
Block a user