@calebcall/camera-ui-notify 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,100 @@ All notable changes to **Notify** are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.8.0] - 2026-08-11
11
+
12
+ ### Fixed
13
+
14
+ - **No more duplicate notifications for AI descriptions.** camera.ui announces a detection, then
15
+ republishes the same notification once the AI description is ready — same `tag`, plus
16
+ `silent: true` to mark it as an update rather than a new alert. Both fields were previously
17
+ ignored, so every detection arrived twice, both times with sound. Now:
18
+ - **Telegram** and **Discord** replace the original message in place (`editMessageText` /
19
+ `editMessageCaption`, `PATCH .../messages/{id}`), so one notification's text improves rather
20
+ than a second one appearing. Message ids are held in memory per tag for 15 minutes; after a
21
+ restart, or if the original was deleted, the update falls back to a new quiet message. Only
22
+ the `silent` follow-up replaces — detection tags repeat across events, so a new alert reusing
23
+ a tag always posts a new message rather than rewriting chat history.
24
+ - **ntfy** (priority `1`), **Gotify** (priority `3`) and **Pushover** (priority `-1`) deliver the
25
+ update without sound or vibration.
26
+ - **Grafana** resolves the update onto the event it already opened: annotations mode patches the
27
+ existing annotation's text, Alertmanager and IRM modes re-send under the same fingerprint so
28
+ the alert updates instead of a second one firing.
29
+ - The **generic webhook** payload forwards the new `silent` field alongside the existing `tag`.
30
+ - `critical` notifications ignore `silent` and always alert, per the SDK contract.
31
+
32
+ - **README no longer documents `grafana_irm_ttl`.** The field was removed from the code in 0.7.1
33
+ when IRM turned out to ignore `endsAt`, but its row survived in the config table — directly
34
+ contradicting the paragraph below it stating that IRM groups do not auto-resolve.
35
+
36
+ ### Added
37
+
38
+ - **"Follow-up updates" setting** (`silent_updates`) — `Deliver quietly (no sound)` (default) or
39
+ `Skip the update entirely`, for anyone who wants exactly one notification per event on the
40
+ backends that can't replace a delivered message. Backends that replace in place still receive
41
+ the update under `skip`, since it adds no entry to the notification list.
42
+
43
+ ### Changed
44
+
45
+ - **camera.ui Go SDK bumped to v1.2.22** (from v1.2.6) for `Notification.Silent`.
46
+ - **Linting consolidated on golangci-lint**, configured by a new `.golangci.yml`. `npm run lint`
47
+ previously chained `staticcheck && golangci-lint run`, so a staticcheck finding short-circuited
48
+ the `&&` and golangci-lint never ran at all. golangci-lint already bundles staticcheck's
49
+ analyzers, so the standalone binary is gone from the script; the config excludes three
50
+ deliberate idioms (`defer resp.Body.Close()`, and nil-`Context` / De Morgan findings in tests)
51
+ and `npm run lint` now reports zero issues.
52
+ - **`package.json` description now lists every backend.** It had said "ntfy, Gotify, or a generic
53
+ webhook" since 0.4.0, omitting Pushover, Telegram, Discord and Grafana. This is the text npm
54
+ shows on the package listing. ([#29](https://github.com/calebcall/camera-ui-notify/issues/29))
55
+
56
+ ## [0.7.1] - 2026-08-02
57
+
58
+ ### Fixed
59
+
60
+ - **Alertmanager mode: documented the required path prefix.** Mimir and Grafana Cloud serve the
61
+ Alertmanager API under a prefix (`/alertmanager` by default); a standalone Alertmanager serves it
62
+ at the root. 0.7.0's field placeholder and README both showed a bare host, so configuring it the
63
+ documented way produced `404 page not found` on every send. The README now gives both forms side
64
+ by side, and the field description and placeholder show the Grafana Cloud shape. No code was
65
+ wrong here — the documentation was.
66
+ - **A 404 from Alertmanager now explains itself.** Go's default mux answers with a bare
67
+ `404 page not found`, which says nothing about the missing prefix that caused it. The error now
68
+ adds that hint. Other statuses are untouched.
69
+ - **A pasted full endpoint is accepted.** Alertmanager's own docs show the complete
70
+ `.../api/v2/alerts` URL, so copying it into the base-URL field is the obvious mistake; a trailing
71
+ `/api/v2/alerts` is now trimmed rather than producing `/api/v2/alerts/api/v2/alerts`.
72
+ - **Documented where Grafana Cloud Alertmanager credentials come from.** The username is the
73
+ numeric Alertmanager instance ID from the Cloud portal; the password is an Access Policy token
74
+ (`glc_...`) with the `alerts:write` scope — *not* a Grafana service-account token (`glsa_...`),
75
+ which authenticates to Grafana rather than to the Alertmanager.
76
+
77
+ ### Changed
78
+
79
+ - **Alertmanager mode no longer sends `startsAt`**, letting Alertmanager stamp it from its own
80
+ clock. The alert's start time is now correct even on a host whose clock has drifted.
81
+ - **`grafana_ttl` default raised from 300 to 900 seconds.** `endsAt` must be absolute — the
82
+ Alertmanager API has no relative form — so it is still derived from the camera.ui host's clock. A
83
+ host running more than `grafana_ttl` behind the Alertmanager sends an `endsAt` already in the
84
+ past, and the alert is accepted with a `200`, resolved on arrival, and never appears as active —
85
+ a silent no-op that reads as success. 15 minutes gives that failure real margin where 5 did not,
86
+ and the README now names the symptom and the fix (keep the host in NTP sync).
87
+
88
+ ### Removed
89
+
90
+ - **`grafana_irm_ttl` and the IRM `endsAt` experiment, both added in 0.7.0.** They did nothing. IRM
91
+ decides whether a group is resolved from a template on the payload's status — its default is
92
+ `{{ payload.status == "resolved" }}` — and ignores `endsAt` entirely, so no value in a single
93
+ firing request can close a group; only a second request can. IRM alerts once again carry the
94
+ documented never-resolves sentinel `0001-01-01T00:00:00Z`, which states the actual behaviour
95
+ instead of implying an auto-close that never happens, and the README says plainly that groups are
96
+ closed by hand.
97
+
98
+ A delayed resolve was considered and rejected: it would require a background timer and per-event
99
+ state in a plugin that is otherwise one stateless POST per event, and a restart would strand the
100
+ group open regardless.
101
+
8
102
  ## [0.7.0] - 2026-08-02
9
103
 
10
104
  ### Changed
package/README.md CHANGED
@@ -69,7 +69,7 @@ Delivers to any HTTP endpoint you provide — the fallback for anything without
69
69
  | `headerName` | no | — | Optional custom header name (e.g. for a shared secret). Requires `headerValue` if set. |
70
70
  | `headerValue` | no | — | Value of the custom header. Requires `headerName` if set. |
71
71
 
72
- Delivery: `{method} {url}` with `Content-Type: application/json` and (if configured) the custom header, carrying a JSON body of `{title, subtitle, body, severity, tag, imageUrl, deepLink, data, createdAt, thumbnailBase64}`.
72
+ Delivery: `{method} {url}` with `Content-Type: application/json` and (if configured) the custom header, carrying a JSON body of `{title, subtitle, body, severity, tag, silent, imageUrl, deepLink, data, createdAt, thumbnailBase64}`.
73
73
 
74
74
  ### Pushover
75
75
 
@@ -115,13 +115,12 @@ different services, so each has its own connection fields.
115
115
  | `grafana_server` | yes | annotations | Base URL of the Grafana instance. Trailing `/` trimmed. |
116
116
  | `grafana_token` | yes | annotations | Service-account token, sent as `Authorization: Bearer <token>`. |
117
117
  | `grafana_tags` | no | annotations | Comma-separated extra tags. |
118
- | `grafana_am_url` | yes | alertmanager | Base URL of the **Alertmanager**, not of Grafana. Trailing `/` trimmed. |
119
- | `grafana_am_user` | no | alertmanager | Basic-auth username. For Grafana Cloud, the Alertmanager instance ID. |
120
- | `grafana_am_password` | no | alertmanager | Basic-auth password. For Grafana Cloud, an API token. Required if a username is set, and vice versa. |
118
+ | `grafana_am_url` | yes | alertmanager | Base URL of the **Alertmanager**, not of Grafana — **including any path prefix** (see below). A pasted `.../api/v2/alerts` endpoint is accepted and trimmed. |
119
+ | `grafana_am_user` | no | alertmanager | Basic-auth username. For Grafana Cloud, the numeric Alertmanager instance ID. |
120
+ | `grafana_am_password` | no | alertmanager | Basic-auth password. For Grafana Cloud, an Access Policy token with the `alerts:write` scope. Required if a username is set, and vice versa. |
121
121
  | `grafana_alertname` | no | alertmanager | `alertname` label. Defaults to `CameraUINotification`. |
122
- | `grafana_ttl` | no | alertmanager | Seconds before Alertmanager auto-resolves the alert. Default `300`, minimum `30`. |
122
+ | `grafana_ttl` | no | alertmanager | Seconds before Alertmanager auto-resolves the alert. Default `900`, minimum `30`. |
123
123
  | `grafana_irm_url` | yes | irm | Inbound webhook URL of an IRM / OnCall integration. The token is in the URL, so it is masked and kept out of every error message. |
124
- | `grafana_irm_ttl` | no | irm | Seconds before the alert is eligible to auto-resolve. Default `300`, minimum `30`. Whether IRM acts on it depends on the integration's templates. |
125
124
 
126
125
  **Annotations** — `POST {server}/api/annotations` with a point-in-time, organization-wide
127
126
  annotation tagged `camera.ui`, `camera:<name>`, `severity:<level>`, plus your extra tags. Surface it
@@ -140,8 +139,35 @@ Cloud's hosted Alertmanager (username = instance ID, password = API token).
140
139
  > rule evaluation. Point this at a real Alertmanager. (Versions 0.6.0–0.6.1 targeted Grafana here
141
140
  > and always failed with `400 bad request data`.)
142
141
 
143
- `endsAt` is `startsAt + grafana_ttl`, which lets Alertmanager auto-resolve the alert without a
144
- second request. Labels are `alertname`, `source=camera.ui`, `severity` (camera.ui's own four
142
+ > **Get the URL right — this is the most common way to misconfigure this mode.** Mimir and Grafana
143
+ > Cloud serve the Alertmanager API under a path prefix, `/alertmanager` by default; a standalone
144
+ > Alertmanager serves it at the root. Omit the prefix and every send fails with a bare
145
+ > `404 page not found`.
146
+ >
147
+ > | Target | Enter | Resulting POST |
148
+ > | --- | --- | --- |
149
+ > | Grafana Cloud / Mimir | `https://alertmanager-prod-xx.grafana.net/alertmanager` | `…/alertmanager/api/v2/alerts` |
150
+ > | Standalone Alertmanager | `http://alertmanager:9093` | `…/api/v2/alerts` |
151
+ >
152
+ > **Grafana Cloud credentials** come from two different places. The **username** is the numeric
153
+ > Alertmanager instance ID, shown with the URL on the Alertmanager details page in the Cloud
154
+ > portal. The **password** is an **Access Policy token** (`glc_…`) carrying the `alerts:write`
155
+ > scope, created under Access Policies — *not* a Grafana service-account token (`glsa_…`), which
156
+ > authenticates to Grafana rather than to the Alertmanager.
157
+
158
+ `endsAt` is `now + grafana_ttl`, which lets Alertmanager auto-resolve the alert without a
159
+ second request. `startsAt` is deliberately not sent — Alertmanager stamps it from its own clock.
160
+
161
+ > **If sends succeed but no alert appears, check the clock.** `endsAt` has to be absolute
162
+ > (Alertmanager's API has no relative form), so it is computed from the camera.ui host's clock. A
163
+ > host running more than `grafana_ttl` *behind* the Alertmanager sends an `endsAt` already in the
164
+ > past: the alert is accepted with a `200`, resolved on arrival, and never shows as active. The
165
+ > symptom is a clean `notify: delivered` in the log and an empty
166
+ > `GET {alertmanager}/api/v2/alerts`. Keep the host in NTP sync. The 900-second default exists to
167
+ > give that failure some margin.
168
+ >
169
+ > Second, gentler trap: alerts self-resolve after `grafana_ttl` and drop off the active list, so
170
+ > when testing, look within the window rather than an hour later. Labels are `alertname`, `source=camera.ui`, `severity` (camera.ui's own four
145
171
  levels, verbatim), `camera`, `camera_id`, and a unique `event_id` — the last of these matters, because
146
172
  Alertmanager deduplicates on the label set and without it two detections on one camera inside the
147
173
  TTL window would collapse into a single alert. The absolute deep link becomes `generatorURL`,
@@ -157,11 +183,14 @@ labels/annotations/`generatorURL`/`imageURL`, `groupKey`, `commonLabels`, `exter
157
183
 
158
184
  Alert groups are keyed **per camera** — `camera.ui:<camera>`, falling back to `camera.ui` for a
159
185
  notification that names no camera — so one busy camera can't bury a quiet one. Within a group each
160
- event keeps its own `fingerprint`, so detections stay individually visible. Each alert carries a
161
- future `endsAt` (`startsAt + grafana_irm_ttl`), the same way Alertmanager mode expresses
162
- "resolves on its own at this time"; whether IRM acts on it depends on the integration's templates,
163
- and if it does not, groups stay open until you resolve them by hand. There is still no follow-up
164
- `state: "ok"` request every mode is one stateless POST per event.
186
+ event keeps its own `fingerprint`, so detections stay individually visible.
187
+
188
+ **IRM groups do not auto-resolve.** IRM decides that from a template on the payload's status — its
189
+ default is `{{ payload.status == "resolved" }}` so a group closes only when a *second* request
190
+ arrives saying so. `endsAt` is ignored, which is why alerts carry the documented never-resolves
191
+ sentinel rather than a future time that would imply a close that never comes. This plugin sends one
192
+ stateless POST per event and no follow-up, by design: a delayed resolve would mean a background
193
+ timer and per-event state, and a restart would strand the group open anyway. Close them in IRM.
165
194
 
166
195
  > **Camera names:** `camera` carries the camera's display name, taken from `Data["cameraName"]`
167
196
  > when a publisher supplies one and otherwise from the deep link, which camera.ui routes by name.
@@ -178,6 +207,32 @@ and if it does not, groups stay open until you resolve them by hand. There is st
178
207
  > **Secrets in logs:** transport failures never log the bot token / webhook URL / other
179
208
  > URL-embedded secret — request URLs are redacted from delivery errors.
180
209
 
210
+ ## Follow-up updates (AI descriptions)
211
+
212
+ camera.ui announces a detection immediately, then republishes the same notification a few seconds later once the AI description is ready. Both publishes carry the same `tag` (the collapse key), and the second carries `silent: true`, meaning *this only updates the first one — don't alert again*.
213
+
214
+ Handled per backend, according to what each platform can actually do:
215
+
216
+ | Backend | Follow-up behaviour |
217
+ | --------------- | ------------------------------------------------------------------------------------ |
218
+ | Telegram | **Replaces** the original message (`editMessageText` / `editMessageCaption`). |
219
+ | Discord | **Replaces** the original message (`PATCH .../messages/{id}`). |
220
+ | Grafana | **Revises** the record it already filed — see below. |
221
+ | ntfy | Delivered at priority `1` — visible, no sound or vibration. |
222
+ | Gotify | Delivered at priority `3` — joins the in-app list, raises no system notification. |
223
+ | Pushover | Delivered at priority `-1` (quiet) — no sound or vibration. |
224
+ | Generic webhook | `silent: true` is forwarded in the JSON payload; your endpoint decides. |
225
+
226
+ Grafana revises per mode: **annotations** patches the annotation it created (`PATCH /api/annotations/:id`), so the dashboard keeps one marker at the detection's own timestamp whose text improves. **Alertmanager** and **IRM** re-file under the `event_id` / `alert_uid` the first alert used — that identity is what each surface deduplicates on, so the existing alert or group picks up the description instead of a second one firing. When the publisher supplies its own `Data["eventId"]`, that id is authoritative and the two publishes already share it, so those modes update correctly even across a plugin restart.
227
+
228
+ The replacing backends therefore show **one** notification whose text improves in place. The message id is remembered in memory per tag for 15 minutes; after a plugin restart, or if the original message was deleted, the update is delivered as a new (quiet) message instead of being lost.
229
+
230
+ Only the `silent` follow-up replaces. Detection tags repeat across events (`motion:cam-1` is the same tag every time that camera sees something), so a *new* alert reusing a tag always posts a new message — your chat history is never rewritten by a later event.
231
+
232
+ **Critical alerts ignore `silent`** — a `critical` severity notification always alerts, per the SDK contract.
233
+
234
+ If you would rather never see the follow-up on a backend that can't replace, set **Follow-up updates** to `Skip the update entirely` in the plugin settings. Backends that replace in place still receive it under that setting, since editing adds nothing to the notification list — with the one caveat that a lost message id (restart, deleted message) turns that edit into a new quiet message.
235
+
181
236
  ## Configuring your target (v1: one active target)
182
237
 
183
238
  There is no "add device" flow. Instead, configure the plugin itself:
@@ -185,7 +240,8 @@ There is no "add device" flow. Instead, configure the plugin itself:
185
240
  1. Open the **Notify** plugin's page in camera.ui (Plugins → Notify).
186
241
  2. In its settings, pick a **Service** (`ntfy`, `Gotify`, `Generic webhook`, `Pushover`, `Telegram`, `Discord`, or `Grafana`) from the dropdown built from the registered backends.
187
242
  3. Fill in that service's fields — only the selected service's fields are shown; the rest are condition-gated out.
188
- 4. Save. The config is validated (`ParseTarget`) the next time a notification is dispatched; `getDevices` then synthesizes one delivery target from it, and notifications from any publisher are delivered there.
243
+ 4. Optionally set **Follow-up updates** (see [Follow-up updates](#follow-up-updates-ai-descriptions)) defaults to delivering the AI description quietly.
244
+ 5. Save. The config is validated (`ParseTarget`) the next time a notification is dispatched; `getDevices` then synthesizes one delivery target from it, and notifications from any publisher are delivered there.
189
245
 
190
246
  This is a **single, instance-wide target** in v1 — there's no way to register several devices at once. Changing the config replaces the previous target rather than adding to it. Delivery for that one target is a single request per notification (no fan-out to worry about, since there's only one device).
191
247
 
@@ -288,9 +344,24 @@ dispatch logic in `notifier.go` all pick up the new backend automatically.
288
344
 
289
345
  ```bash
290
346
  go test ./src/... # full suite
291
- go test ./src/... -race -count=1 # race detector
347
+ go test ./src/... -race -count=1 # race detector (needs cgo: apt-get install gcc)
348
+ npm run lint # golangci-lint, configured by .golangci.yml
349
+ npm run format # gofmt + go fix
292
350
  ```
293
351
 
352
+ Linting is golangci-lint only. It bundles staticcheck's analyzers (`SA`/`S`/`ST`/`QF`), so running
353
+ the standalone `staticcheck` binary alongside it only duplicates findings — and, being a separate
354
+ tool, it can't read the path-scoped exclusions in `.golangci.yml`.
355
+
356
+ ```bash
357
+ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
358
+ ```
359
+
360
+ `.golangci.yml` excludes three deliberate idioms, each scoped as narrowly as the linter allows so
361
+ the check stays live everywhere else: `defer resp.Body.Close()` (errcheck), and — in `_test.go`
362
+ only — passing a nil `Context` to exercise each backend's `ctx == nil` fallback (SA1012) and the
363
+ `!(a <= b && b <= c)` monotonicity assertions (QF1001).
364
+
294
365
  ## License
295
366
 
296
367
  [MIT](./LICENSE.md).
package/bundle.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "displayName": "Notify",
3
3
  "name": "@calebcall/camera-ui-notify",
4
- "version": "0.7.0",
5
- "description": "A fully-local, multi-backend camera.ui notifier plugin: delivers notifications to ntfy, Gotify, or a generic webhook, entirely on your own hardware with no cloud dependency.",
4
+ "version": "0.8.0",
5
+ "description": "A fully-local, multi-backend camera.ui notifier plugin: delivers notifications to ntfy, Gotify, Pushover, Telegram, Discord, Grafana, or a generic webhook, entirely on your own hardware with no cloud dependency.",
6
6
  "author": "calebcall (https://github.com/calebcall/camera-ui-notify)",
7
7
  "main": "./main.go",
8
8
  "type": "commonjs",
@@ -12,7 +12,7 @@
12
12
  "bundle:dev": "npm run build && cross-env MODE=development cui bundle",
13
13
  "format": "gofmt -w ./src/* && go fix ./src/...",
14
14
  "install-updates": "npm i --save --force && go mod tidy",
15
- "lint": "staticcheck ./src/... && golangci-lint run ./src/...",
15
+ "lint": "golangci-lint run ./src/...",
16
16
  "prepublishOnly": "node -e \"if(!process.env.SAFE_PUBLISH){console.error('Error: Please use @camera.ui/cli to publish the plugin:\\n npm run publish:alpha\\n npm run publish:beta\\n npm run publish:latest\\n');process.exit(1)}\"",
17
17
  "publish:alpha": "npm i --save --force && npm run bundle && cui publish --alpha",
18
18
  "publish:beta": "npm i --save --force && npm run bundle && cui publish --beta",
@@ -48,14 +48,14 @@
48
48
  "protocolLevel": 1
49
49
  },
50
50
  "optionalDependencies": {
51
- "@calebcall/camera-ui-notify-darwin-arm64": "0.7.0",
52
- "@calebcall/camera-ui-notify-darwin-amd64": "0.7.0",
53
- "@calebcall/camera-ui-notify-linux-amd64": "0.7.0",
54
- "@calebcall/camera-ui-notify-linux-arm64": "0.7.0",
55
- "@calebcall/camera-ui-notify-windows-amd64": "0.7.0",
56
- "@calebcall/camera-ui-notify-windows-arm64": "0.7.0",
57
- "@calebcall/camera-ui-notify-linux-amd64-musl": "0.7.0",
58
- "@calebcall/camera-ui-notify-linux-arm64-musl": "0.7.0"
51
+ "@calebcall/camera-ui-notify-darwin-arm64": "0.8.0",
52
+ "@calebcall/camera-ui-notify-darwin-amd64": "0.8.0",
53
+ "@calebcall/camera-ui-notify-linux-amd64": "0.8.0",
54
+ "@calebcall/camera-ui-notify-linux-arm64": "0.8.0",
55
+ "@calebcall/camera-ui-notify-windows-amd64": "0.8.0",
56
+ "@calebcall/camera-ui-notify-windows-arm64": "0.8.0",
57
+ "@calebcall/camera-ui-notify-linux-amd64-musl": "0.8.0",
58
+ "@calebcall/camera-ui-notify-linux-arm64-musl": "0.8.0"
59
59
  },
60
60
  "files": [
61
61
  "bundle.zip",