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