Compare commits

..
17 Commits
Author SHA1 Message Date
ahp 998732e98b docs(man): split the movement keys from the next entry
A missing line break merged the .TP macro into the previous sentence, so the g/G entry rendered literally as ".TP".
2026-10-11 12:35:00 +03:30
ahp 54aa87676d docs(readme): rewrite for a clearer, complete overview
Add badges, a contents list, and a screenshot caption; document every flag in a table, topic-resolution rules, the --since replay window, the publish form, and the adaptive pane layout. Add How it works, History and storage, and Troubleshooting sections, and correct the go install instructions.
2026-10-11 12:34:53 +03:30
ahp 746eeccd5c docs: document packaging and system-wide installation
Describe installing from the Arch and Debian packages, system-wide installs via PREFIX/DESTDIR, the packaging release flow, and add the new directories to the project layout.
2026-10-11 12:20:52 +03:30
ahp e03d6bb195 build(make): add deb and arch targets and a staged install
install now honors DESTDIR and also installs the license and README under DOCDIR, so packagers can stage a filesystem tree. Add deb (runs dpkg-buildpackage, collects the artifacts in dist/) and arch (runs makepkg) targets, plus uninstall. Ignore the Debian and Arch build output.
2026-10-11 12:20:47 +03:30
ahp 764bffac20 build(arch): add an Arch Linux PKGBUILD
Build the v0.1.0 release archive, compile a stripped, CGO-free binary, run the test suite in check(), and install the binary, man page, license, and README. The source checksum is pinned and .SRCINFO is generated.
2026-10-11 12:20:40 +03:30
ahp e6499b50bb build(debian): add a Debian and Ubuntu package definition
Add a debhelper-free debian/ tree so a .deb can be built with the Go toolchain and dpkg-dev alone. debian/rules compiles the binary with -trimpath and the version from debian/changelog, then stages the binary, the lazyntfy(1) man page, the license, and the README. The package Depends on ca-certificates for TLS to the ntfy server.
2026-10-11 12:20:34 +03:30
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
45 changed files with 5179 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
+33
View File
@@ -0,0 +1,33 @@
# 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
# Debian packaging build output
/debian/files
/debian/lazyntfy/
/debian/*.substvars
/debian/.debhelper/
# Arch Linux packaging build output
/packaging/archlinux/src/
/packaging/archlinux/pkg/
/packaging/archlinux/*.pkg.tar.*
/packaging/archlinux/*.tar.gz
+63
View File
@@ -0,0 +1,63 @@
BINARY := lazyntfy
PREFIX ?= $(HOME)/.local
BINDIR ?= $(PREFIX)/bin
MANDIR ?= $(PREFIX)/share/man/man1
DOCDIR ?= $(PREFIX)/share/doc/$(BINARY)
DESTDIR ?=
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' | grep . || echo 0.1.0)
TOPIC ?= alerts
GO ?= go
GOFLAGS ?= -trimpath
LDFLAGS := -X main.version=$(VERSION)
.PHONY: all build install uninstall man test vet fmt run deb arch clean
all: build
build:
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/lazyntfy
# System-wide install: sudo make install PREFIX=/usr/local
# Staged install: make install DESTDIR=/tmp/pkg PREFIX=/usr
install: build
install -Dm755 $(BINARY) "$(DESTDIR)$(BINDIR)/$(BINARY)"
install -Dm644 man/lazyntfy.1 "$(DESTDIR)$(MANDIR)/lazyntfy.1"
install -Dm644 LICENSE "$(DESTDIR)$(DOCDIR)/copyright"
install -Dm644 README.md "$(DESTDIR)$(DOCDIR)/README.md"
uninstall:
rm -f "$(DESTDIR)$(BINDIR)/$(BINARY)" "$(DESTDIR)$(MANDIR)/lazyntfy.1"
rm -rf "$(DESTDIR)$(DOCDIR)"
man:
install -Dm644 man/lazyntfy.1 "$(DESTDIR)$(MANDIR)/lazyntfy.1"
test:
$(GO) test ./...
vet:
$(GO) vet ./...
fmt:
gofmt -w .
run: build
./$(BINARY) $(TOPIC)
# Build a Debian/Ubuntu package. -d skips build-dependency checks so the
# package can also be built on a machine without golang-go installed as a
# distro package.
deb:
dpkg-buildpackage -us -uc -b -d
mkdir -p dist
mv -f ../$(BINARY)_*_*.deb ../$(BINARY)_*_*.changes ../$(BINARY)_*_*.buildinfo dist/ 2>/dev/null || true
@echo " packages written to dist/"
arch:
cd packaging/archlinux && makepkg -f
clean:
rm -f $(BINARY)
rm -rf dist
-$(MAKE) -f debian/rules clean
+366
View File
@@ -1,2 +1,368 @@
# lazyntfy
**A [lazygit](https://github.com/jesseduffield/lazygit)-style terminal client for [ntfy](https://ntfy.sh).**
Follow your topics, read and search messages, and publish — without leaving the terminal.
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](LICENSE)
[![Built with Bubble Tea](https://img.shields.io/badge/built%20with-Bubble%20Tea-ff69b4)](https://github.com/charmbracelet/bubbletea)
[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](#contributing)
```
╭ 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 is a keyboard-driven TUI for [ntfy](https://ntfy.sh): the left pane lists
topics, the middle pane lists messages, and the right pane shows the selected
message in full. It talks to the ntfy HTTP API directly, follows several topics
over a single stream, and keeps a local cache so your history is there the moment
you launch it.
## Contents
- [Features](#features)
- [Install](#install)
- [Quick start](#quick-start)
- [Usage](#usage)
- [Key bindings](#key-bindings)
- [Configuration](#configuration)
- [How it works](#how-it-works)
- [History and storage](#history-and-storage)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Packaging](#packaging)
- [Project layout](#project-layout)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Three-pane, lazygit-style layout** — topics, messages, and full detail side by side.
- **Follow many topics at once** — topics that share a server and credentials are
multiplexed onto a single stream.
- **Account discovery** — start with no topics and lazyntfy lists your
subscriptions, reserved topics, and ACL grants.
- **Alert-aware** — Grafana and Alertmanager webhook payloads are summarized into
status, alert name, severity, and summary; `v` reveals the raw JSON.
- **Persistent** — topics and messages are cached in a local SQLite database and
restored on the next start, so the list is populated as soon as the app opens.
- **Resilient** — reconnects with exponential backoff and resumes from the newest
event it has already seen, so nothing is delivered twice.
- **Publish from the terminal** — press `p` to send a title, priority, tags, and body.
- **Single static binary** — pure Go, no CGo, including the SQLite driver.
## Install
lazyntfy needs a **Go 1.26+ toolchain only to build**. Prebuilt installs are
available for Arch Linux and Debian/Ubuntu.
### Arch Linux
A `PKGBUILD` lives in [`packaging/archlinux`](packaging/archlinux). Build and
install the package with `makepkg`:
```sh
git clone https://src.panahifar.ir/ahp/lazyntfy.git
cd lazyntfy/packaging/archlinux
makepkg -si
```
This installs `/usr/bin/lazyntfy` and the man page under `/usr/share/man`, and
pulls in `ca-certificates` so TLS to your ntfy server works.
### Debian and Ubuntu
A debhelper-free source package lives in [`debian`](debian). Build a `.deb` and
install it system-wide:
```sh
git clone https://src.panahifar.ir/ahp/lazyntfy.git
cd lazyntfy
make deb # writes dist/lazyntfy_<version>_<arch>.deb
sudo apt install ./dist/lazyntfy_*.deb
```
`apt install ./file.deb` (or `dpkg -i` followed by `apt -f install`) resolves the
`ca-certificates` dependency. The packaging needs only the Go toolchain and
`dpkg-dev`, and `make deb` passes `-d` so it also builds where `golang-go` is not
installed as a distribution package.
### From source
```sh
git clone https://src.panahifar.ir/ahp/lazyntfy.git
cd lazyntfy
make install # into ~/.local, no root needed
```
`make install` honors `PREFIX`, `BINDIR`, `MANDIR`, `DOCDIR`, and `DESTDIR`.
Choosing a system prefix installs for every user:
```sh
sudo make install PREFIX=/usr/local # /usr/local/bin/lazyntfy, /usr/local/share/man/man1
sudo make install PREFIX=/usr # /usr/bin/lazyntfy, /usr/share/man/man1
```
Stage a filesystem tree into a temporary root instead of the live system (handy
for building your own package):
```sh
make install DESTDIR="$PWD/stage" PREFIX=/usr
```
### With `go install`
From a checkout:
```sh
go install ./cmd/lazyntfy # binary in $(go env GOPATH)/bin
```
## Quick start
```sh
lazyntfy alerts # follow one topic on the default host
lazyntfy # or start with no arguments to load every topic on your account
```
Then `j`/`k` to move, `enter` to open a message, `/` to filter, `p` to publish,
and `?` for help.
## Usage
```
lazyntfy [flags] [topic...]
```
### Flags
| Flag | Default | Description |
| --- | --- | --- |
| `--config path` | `~/.config/ntfy/client.yml` | Read the ntfy client config from `path`. |
| `--host url` | from the config file | Override `default-host`. |
| `--token token` | from the config file | Send `token` as a Bearer credential. |
| `--user user[:password]` | from the config file | Send HTTP basic credentials. |
| `--since value` | `all` | Replay window: `all`, a duration, a Unix timestamp, or a message id. |
| `--version` | | Print the version and exit. |
| `--help` | | Print a short usage summary and exit. |
### Specifying topics
Topics can be passed as positional arguments or listed in `client.yml`:
- A **short name** is expanded with `default-host`: `lazyntfy alerts`.
- A **`host/topic`** or a **full URL** carries its own origin:
`lazyntfy ntfy.example:8443/alerts` or `lazyntfy https://other.example/backups`.
- Short names need a `default-host`; pass `--host` or use a `host/topic` form.
With **no positional topics**, lazyntfy assembles the subscription list from, in
order:
1. the `subscribe:` list in `client.yml`;
2. your account — subscriptions and reservations from `GET /v1/account`, and ACL
grants from `GET /v1/users` when the credential may read it;
3. topics remembered from previous sessions.
Press `a` at any time to add a topic for the current session.
### Replaying history
`--since` sets the `since` query parameter for each stream: `all` (the default),
a duration such as `1h` or `30m`, a Unix timestamp, or a message id.
When history was restored from disk and `--since` is left at its default,
lazyntfy sets `since` to the newest stored event instead, so the first connection
picks up where the last session stopped instead of replaying the server's whole
cache. An explicit `--since` always wins.
### Publishing
Press `p` to open a small form with four fields — **title**, **priority**,
**tags**, and **message**:
- **priority** accepts `1`–`5` or the ntfy names `min`, `low`, `default`, `high`, `max`
(empty means `default`).
- **tags** is a comma-separated list.
- **message** is required; it is published as plain text with `Title`, `Priority`,
and `Tags` headers.
## Key bindings
Keys act on the focused pane. Use `tab` / `shift-tab` or `1` / `2` / `3` to change
panes.
| Key | Action |
| --- | --- |
| `j` / `k`, `↓` / `↑` | move in the focused pane |
| `g` / `G`, `home` / `end` | first / last |
| `ctrl-u` / `ctrl-d`, `pgup` / `pgdn` | page up / down |
| `1` / `2` / `3` | focus topics / messages / detail |
| `tab` / `shift-tab` | next / previous pane |
| `enter` | descend: topics → messages → detail |
| `esc` | leave detail, or clear the filter |
| `/` | filter by title, body, tags, and message id |
| `r` | reconnect all streams |
| `p` | publish to the focused topic |
| `a` | subscribe to another topic for this session |
| `v` | toggle the raw JSON view |
| `o` | open the message's click URL |
| `?` | in-app help |
| `q` / `ctrl-c` | quit |
The layout adapts to the terminal width: at 100 columns and wider all three panes
are shown; from 72–99 columns the topics pane is paired with the focused pane;
and below 72 columns only the focused pane is drawn. Terminals smaller than 40×8
show `terminal too small`.
## Configuration
lazyntfy reads the official ntfy client configuration at
`~/.config/ntfy/client.yml`:
```yaml
default-host: https://ntfy.example
default-token: tk_xxxxxxxxxxxxxxxx
# or, instead of a token:
# default-user: alice
# default-password: s3cret
subscribe:
- topic: alerts
- topic: https://other.example/backups # explicit origin
user: bob
password: hunter2
- topic: private # per-topic token
token: tk_yyyyyyyyyyyyyyyy
```
**Precedence.** `--host`, `--token`, and `--user` override the file. A token beats
a user: passing `--user` clears the token, and passing `--token` clears the user.
A per-topic `token` or `user` in `subscribe` applies to that topic only. Credentials
are sent in the `Authorization` header (Bearer or Basic) and are never printed or
written to disk.
If the default file is missing and you pass topics on the command line, lazyntfy
still runs. Point `--config` at a different file to load another one.
## How it works
- **Subscribe** — `GET /<topic>[,<topic>...]/json` returning NDJSON, with a `since`
query parameter per stream.
- **Publish** — `POST /<topic>` with a plain-text body and `Title` / `Priority` /
`Tags` headers.
- **Grouping** — topics that share an origin and credentials share one connection;
each group is supervised independently.
- **Reconnect** — on a dropped stream the group retries with exponential backoff
(1s, doubling up to 30s) and sets `since` to the newest event time it has already
seen, so messages are not delivered twice.
- **Events** — `message` adds a message, while `message_delete` and `message_clear`
remove messages locally as well. Malformed lines are skipped and reported in the
status bar.
- **Truncated cache** — if the server caps its replay it sends
`X-Messages-Truncated`, and lazyntfy notes that the cache was truncated to the
newest messages.
## History and storage
Topics and messages are kept in a small SQLite database so the last session is
restored on the next start.
- **Path** — `$XDG_DATA_HOME/lazyntfy/history.db`, defaulting to
`~/.local/share/lazyntfy/history.db`.
- **Contents** — remembered topics, the newest 1000 messages per topic, and a little
UI state such as the last selected topic.
- **Maintenance** — messages with an `expires` time are pruned on startup, and the
1000-message cap is enforced as messages arrive.
- **Privacy** — credentials are never written to the database; the current run's
authentication is always used.
- **Best effort** — if the database cannot be opened, lazyntfy runs without history.
To start clean, delete the file:
```sh
rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/lazyntfy/history.db"
```
## Troubleshooting
| Symptom | What to do |
| --- | --- |
| `no topics yet — press a to add one` | Pass a topic, add `subscribe:` entries, or use a token so account discovery can list topics. |
| `HTTP 401/403: not authorized to list topics` | The credential cannot read `/v1/account`; pass topics explicitly instead. |
| `no default-host` | Short topic names need a host. Pass `--host`, or use `host/topic`. |
| `x509: certificate signed by unknown authority` | Install CA certificates (`ca-certificates`). |
| `terminal too small` | Resize to at least 40×8. |
| `o` does nothing | Opening the click URL uses `xdg-open`; install it or a desktop opener. |
| First launch shows a flood of messages | Narrow the window with `--since 1h`. |
## 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
make clean # remove the binary and build output
```
Run one package's tests, or the full suite with the race detector:
```sh
go test ./internal/history/...
go test -race ./...
```
## Packaging
Both package formats are defined in the repository. `make deb` builds a Debian
package from the working tree; the Arch `PKGBUILD` builds the `v$pkgver` release
archive from the Gitea instance.
| Format | Build | Definition |
| --- | --- | --- |
| Debian / Ubuntu | `make deb` → `dist/*.deb` | [`debian/`](debian) |
| Arch Linux | `make arch` → `makepkg` | [`packaging/archlinux/PKGBUILD`](packaging/archlinux/PKGBUILD) |
When cutting a release:
1. Bump the version in [`debian/changelog`](debian/changelog) and `pkgver` in the
`PKGBUILD`.
2. Tag the commit (`git tag -s vX.Y.Z`) and push it.
3. In `packaging/archlinux`, run `updpkgsums` to refresh the tarball checksum and
`makepkg --printsrcinfo > .SRCINFO`.
## Project layout
| Path | Purpose |
| --- | --- |
| `cmd/lazyntfy` | flags and program startup |
| `internal/config` | `client.yml`, topic resolution, CLI overrides |
| `internal/history` | on-disk topics and messages (SQLite) |
| `internal/ntfy` | HTTP subscribe, publish, grouping, reconnect |
| `internal/store` | newest-first messages, delete, clear, filter |
| `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 |
| `debian` | Debian / Ubuntu package definition |
| `packaging/archlinux` | Arch Linux `PKGBUILD` |
## 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")
}
}
+5
View File
@@ -0,0 +1,5 @@
lazyntfy (0.1.0) unstable; urgency=medium
* Initial release.
-- Amir Husayn Panahifar <ahp@panahifar.ir> Sun, 11 Oct 2026 12:00:00 +0330
+23
View File
@@ -0,0 +1,23 @@
Source: lazyntfy
Section: utils
Priority: optional
Maintainer: Amir Husayn Panahifar <ahp@panahifar.ir>
Build-Depends: golang-go (>= 2:1.26)
Standards-Version: 4.7.0
Homepage: https://src.panahifar.ir/ahp/lazyntfy
Vcs-Git: https://src.panahifar.ir/ahp/lazyntfy.git
Vcs-Browser: https://src.panahifar.ir/ahp/lazyntfy
Rules-Requires-Root: no
Package: lazyntfy
Architecture: any
Depends: ca-certificates
Description: lazygit-style terminal client for ntfy
lazyntfy is a terminal user interface for the ntfy pub-sub notification
service, laid out like lazygit: topics in the left pane, messages in the
middle pane, and the selected message in the right pane.
.
It speaks the ntfy HTTP API directly, subscribes to several topics over a
single stream, reconnects automatically, summarizes Grafana and Alertmanager
webhook bodies, and remembers topics and messages between runs in a small
SQLite database.
+19
View File
@@ -0,0 +1,19 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: lazyntfy
Source: https://src.panahifar.ir/ahp/lazyntfy
Files: *
Copyright: 2026 Amir Husayn Panahifar <ahp@panahifar.ir>
License: GPL-3.0-or-later
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, either version 3 of the License, or (at your
option) any later version.
.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
.
On Debian systems the full text of the GNU General Public License can be
found in /usr/share/common-licenses/GPL-3.
Vendored Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/make -f
# Debian packaging without debhelper: the Go toolchain builds the binary and
# the rules stage it by hand. This keeps the build dependencies to just Go.
export CGO_ENABLED = 0
PACKAGE := lazyntfy
VERSION := $(shell dpkg-parsechangelog -SVersion)
PKGDIR := debian/$(PACKAGE)
.PHONY: clean build build-arch build-indep binary binary-arch binary-indep
clean:
rm -rf $(PKGDIR) $(PACKAGE)
rm -f debian/files
build: build-arch build-indep
build-arch: $(PACKAGE)
build-indep:
$(PACKAGE):
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(PACKAGE) ./cmd/$(PACKAGE)
binary: binary-arch binary-indep
binary-arch: build-arch
install -d $(PKGDIR)/DEBIAN
install -Dm755 $(PACKAGE) $(PKGDIR)/usr/bin/$(PACKAGE)
install -Dm644 man/lazyntfy.1 $(PKGDIR)/usr/share/man/man1/lazyntfy.1
install -Dm644 LICENSE $(PKGDIR)/usr/share/doc/$(PACKAGE)/copyright
install -Dm644 README.md $(PKGDIR)/usr/share/doc/$(PACKAGE)/README.md
dpkg-gencontrol -p$(PACKAGE) -P$(PKGDIR)
dpkg-deb --build --root-owner-group $(PKGDIR) ..
binary-indep:
+1
View File
@@ -0,0 +1 @@
3.0 (native)
+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
)
+170
View File
@@ -0,0 +1,170 @@
.\" 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.
+16
View File
@@ -0,0 +1,16 @@
pkgbase = lazyntfy
pkgdesc = A lazygit-style terminal client for ntfy
pkgver = 0.1.0
pkgrel = 1
url = https://src.panahifar.ir/ahp/lazyntfy
arch = x86_64
arch = aarch64
license = GPL-3.0-or-later
makedepends = go
depends = ca-certificates
options = !strip
options = !debug
source = lazyntfy-0.1.0.tar.gz::https://src.panahifar.ir/ahp/lazyntfy/archive/v0.1.0.tar.gz
sha256sums = 72c8d2675b4b3241197748793b2e16afd7e1c61102433efbaf0bf93dd59c9141
pkgname = lazyntfy
+38
View File
@@ -0,0 +1,38 @@
# Maintainer: Amir Husayn Panahifar <ahp@panahifar.ir>
pkgname=lazyntfy
pkgver=0.1.0
pkgrel=1
pkgdesc="A lazygit-style terminal client for ntfy"
arch=('x86_64' 'aarch64')
url="https://src.panahifar.ir/ahp/lazyntfy"
license=('GPL-3.0-or-later')
depends=('ca-certificates')
makedepends=('go')
options=('!strip' '!debug')
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('72c8d2675b4b3241197748793b2e16afd7e1c61102433efbaf0bf93dd59c9141')
prepare() {
cd "$pkgname"
go mod download
}
build() {
cd "$pkgname"
export CGO_ENABLED=0
go build -trimpath -ldflags "-s -w -X main.version=$pkgver" -o lazyntfy ./cmd/lazyntfy
}
check() {
cd "$pkgname"
go test ./...
}
package() {
cd "$pkgname"
install -Dm755 lazyntfy "$pkgdir/usr/bin/lazyntfy"
install -Dm644 man/lazyntfy.1 "$pkgdir/usr/share/man/man1/lazyntfy.1"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}