Compare commits

..
11 Commits
Author SHA1 Message Date
ahp ee9e130a92 docs: expand the README and add the lazyntfy(1) man page
Document features, installation, usage, key bindings, configuration, history, development, and project layout. Add a complete man page covering options, keys, files, and exit status.
2026-10-11 12:04:54 +03:30
ahp 714b2da0c7 feat(cmd): wire the CLI flags into the TUI
Parse flags, load configuration, restore remembered topics, prime the stream cursor from history, and run the Bubble Tea program.
2026-10-11 12:04:54 +03:30
ahp 8673f1e83a feat(tui): add the lazygit-style three-pane interface
Topics, messages, and message detail in three panes, with filtering, publishing, adding topics, a raw JSON view, history restore, and open-link support.
2026-10-11 12:04:53 +03:30
ahp 97cc150bf7 feat(history): persist topics and messages in SQLite
Store topics, messages, and small UI preferences in $XDG_DATA_HOME/lazyntfy/history.db. Each topic keeps its newest 1000 messages, expired rows are pruned on startup, and credentials are never written. Saves use a single transaction and prune only when a topic is over the cap.
2026-10-11 12:04:53 +03:30
ahp cd19d4c665 feat(ntfy): add the HTTP client, stream manager, and account discovery
Speak the ntfy JSON API directly: subscribe, publish, group topics that share a server and credentials, reconnect with backoff and a since cursor, and list account subscriptions, reservations, and ACL grants.
2026-10-11 12:04:53 +03:30
ahp 387b3c77f3 feat(theme): add the Catppuccin adaptive palette
Expose light/dark adaptive styles for the chrome, following the lipgloss v2 color API.
2026-10-11 12:04:41 +03:30
ahp 45f18ded00 feat(alert): summarize Grafana and Alertmanager payloads
Extract a readable title and severity from webhook bodies and keep the raw JSON available for the raw view.
2026-10-11 12:04:41 +03:30
ahp 67a843b1e0 feat(store): keep an in-memory newest-first message store
Provide a concurrency-safe, per-topic message store with id-based deduplication, delete, clear, and a search predicate.
2026-10-11 12:04:41 +03:30
ahp ae37b98ef8 feat(config): load ntfy client.yml and resolve topic origins
Parse the official ntfy client configuration, apply command-line overrides, and turn short names, host/topic pairs, and absolute URLs into an origin and topic name.
2026-10-11 12:04:41 +03:30
ahp e412819d48 feat(textutil): add width-aware text wrapping and clipping
Wrap, Clip, and Pad plain text to a fixed rune width for the terminal panes.
2026-10-11 12:04:41 +03:30
ahp eaf7766a4d chore: initialize the Go module and repository scaffolding
Add the Go module definition and lockfile, editor and ignore rules, and a Makefile with build, test, vet, install, and man targets. The version is injected at build time with -ldflags.
2026-10-11 12:04:41 +03:30
38 changed files with 4810 additions and 0 deletions
+25
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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)
+172
View File
@@ -1,2 +1,174 @@
# 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).
+185
View File
@@ -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
}
+88
View File
@@ -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")
}
}
+38
View File
@@ -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
)
+94
View File
@@ -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=
+139
View File
@@ -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
}
+61
View File
@@ -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
}
+215
View File
@@ -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
}
+82
View File
@@ -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")
}
}
+72
View File
@@ -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
}
+27
View File
@@ -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")
}
}
+262
View File
@@ -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)
}
+124
View File
@@ -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)
}
}
+151
View File
@@ -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
}
+82
View File
@@ -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)
}
}
+178
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+72
View File
@@ -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()
}
+37
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+22
View File
@@ -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"))
}
}
+224
View File
@@ -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: &copyEv, 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:
}
}
+101
View File
@@ -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())
}
}
+13
View File
@@ -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
}
+144
View File
@@ -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)
}
+32
View File
@@ -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")
}
}
+72
View File
@@ -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))
}
+32
View File
@@ -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)
}
}
+40
View File
@@ -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)
)
+36
View File
@@ -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()
}
+844
View File
@@ -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)
}
+139
View File
@@ -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())
}
}
+14
View File
@@ -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
}
+609
View File
@@ -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
View File
@@ -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.