NEEDS-KAMI: telegram is the only reach, and it depends on a socks relay that has failed before #196
@@ -25,6 +25,25 @@
|
|||||||
"llm_nudges": false
|
"llm_nudges": false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"//ntfy": [
|
||||||
|
"The second reach (V-649). Until 07-08-2026 telegram was the only one, and",
|
||||||
|
"telegram needs api.telegram.org, the socks relay below and a matching ufw",
|
||||||
|
"rule — three things in series that have each failed once, and when they do",
|
||||||
|
"a sev4 nudge has nowhere to go. ntfy shares none of them: it is reached",
|
||||||
|
"directly, no relay.",
|
||||||
|
"It is not only a spare. The routing table sends sev3-away and away",
|
||||||
|
"reminders here and NOWHERE else, so with this block absent those two",
|
||||||
|
"routes hit a nil sink and vanish without a log or an outbox row.",
|
||||||
|
"The credential is an ntfy access token, scoped write-only to this one",
|
||||||
|
"topic, so a popped sink can push to it and cannot read it back. Set it in",
|
||||||
|
"deploy/telegram.env beside the telegram secrets; that file is gitignored."
|
||||||
|
],
|
||||||
|
"ntfy": {
|
||||||
|
"base_url": "https://ntfy.kvmx.ru",
|
||||||
|
"topic": "maven",
|
||||||
|
"token": "${NTFY_TOKEN}"
|
||||||
|
},
|
||||||
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
||||||
"chat_id": "${TELEGRAM_CHAT_ID}",
|
"chat_id": "${TELEGRAM_CHAT_ID}",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Telegram bot token and chat ID for mavend's away-channel reach.
|
# Secrets for mavend's away-channel reaches. The file is still called
|
||||||
|
# telegram.env because compose names it that; it holds both reaches now.
|
||||||
# Copy this file to deploy/telegram.env and fill in real values.
|
# Copy this file to deploy/telegram.env and fill in real values.
|
||||||
# deploy/telegram.env is gitignored — never commit the real secrets.
|
# deploy/telegram.env is gitignored — never commit the real secrets.
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# ntfy access token for the `maven` topic, the second reach (V-649). Mint it on
|
||||||
|
# the ntfy server with write access to that topic and nothing else:
|
||||||
|
# ntfy token add --expires=never maven
|
||||||
|
# Read access is not needed — mavend publishes and never subscribes.
|
||||||
|
NTFY_TOKEN=
|
||||||
|
|||||||
+12
-2
@@ -1,6 +1,6 @@
|
|||||||
# Start Commands
|
# Start Commands
|
||||||
|
|
||||||
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
*Last verified: 2026-08-07 @ a4630b9. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
|
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
|
||||||
|
|
||||||
@@ -44,7 +44,8 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
|
|||||||
"repeat_interval": "5m",
|
"repeat_interval": "5m",
|
||||||
"ntfy": {
|
"ntfy": {
|
||||||
"base_url": "https://ntfy.kvmx.ru",
|
"base_url": "https://ntfy.kvmx.ru",
|
||||||
"topic": "maven"
|
"topic": "maven",
|
||||||
|
"token": "${NTFY_TOKEN}"
|
||||||
},
|
},
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf",
|
"model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf",
|
||||||
@@ -66,6 +67,15 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
|
|||||||
|
|
||||||
Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups.
|
Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups.
|
||||||
|
|
||||||
|
`${NTFY_TOKEN}` and the `${TELEGRAM_*}` vars are expanded from `deploy/telegram.env`, which is gitignored. Copy `deploy/telegram.env.example` and fill it in. Mint a scoped token rather than reusing an admin one. It needs write access to the `maven` topic and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ntfy access maven maven write-only
|
||||||
|
ntfy token add --expires=never maven
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting the `ntfy` block turns the reach off, and that is not a no-op. The routing table sends sev3-away nudges and away reminders to ntfy and nowhere else. With no sink wired they hit a nil and vanish, leaving no log line and no `delivery_attempts` row (V-649).
|
||||||
|
|
||||||
## mavsttd — STT worker (optional, remote whisper.cpp)
|
## mavsttd — STT worker (optional, remote whisper.cpp)
|
||||||
|
|
||||||
Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so).
|
Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so).
|
||||||
|
|||||||
@@ -45,4 +45,17 @@ func TestDeployConfigLoads(t *testing.T) {
|
|||||||
if cfg.Voice.RouterThreshold <= 0 {
|
if cfg.Voice.RouterThreshold <= 0 {
|
||||||
t.Error("router threshold did not get its default")
|
t.Error("router threshold did not get its default")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The second reach (V-649). Deleting this block is how you turn ntfy off,
|
||||||
|
// so its absence has to be loud: sev3-away nudges and away reminders route
|
||||||
|
// to ntfy and to nothing else, and a nil sink drops them with no log and no
|
||||||
|
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
|
||||||
|
// the wiring and not the credential.
|
||||||
|
if cfg.Ntfy == nil {
|
||||||
|
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
|
||||||
|
"would have nowhere to land, and would vanish silently rather than fail")
|
||||||
|
}
|
||||||
|
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
|
||||||
|
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,17 @@
|
|||||||
// the relay). the dispatcher already strips detail off away sendables; the
|
// the relay). the dispatcher already strips detail off away sendables; the
|
||||||
// sink uses the same helper so it can't leak the body on its own either.
|
// sink uses the same helper so it can't leak the body on its own either.
|
||||||
//
|
//
|
||||||
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
// ntfy is a self-hosted server with deny-all auth — ntfy.kvmx.ru as of
|
||||||
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
// 07-08-2026, reached directly, not through the socks relay telegram needs.
|
||||||
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
|
// maven publishes with a write-only token scoped to its own topic; the
|
||||||
// your phone, nothing else. matches the module key-isolation invariant: the
|
// credential is a delivery-config secret, not a db key. a popped ntfy sink
|
||||||
// sink never holds the sqlcipher key.
|
// can push spam to that one topic, nothing else — it cannot read the topic
|
||||||
|
// back and it never holds the sqlcipher key.
|
||||||
|
//
|
||||||
|
// this is the second reach, and the reason there is one is that telegram was
|
||||||
|
// the only one (V-649). telegram needs api.telegram.org, a socks relay on the
|
||||||
|
// host and a matching ufw rule, three things in series that have each broken
|
||||||
|
// once. ntfy shares none of them.
|
||||||
package ntfysink
|
package ntfysink
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -31,11 +37,29 @@ import (
|
|||||||
// the credential lives in the daemon's config (or a systemd credential),
|
// the credential lives in the daemon's config (or a systemd credential),
|
||||||
// never in the binary.
|
// never in the binary.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path)
|
// BaseURL — the ntfy server, no trailing path. Required.
|
||||||
Topic string // e.g. maven (all maven notifications land here)
|
BaseURL string `json:"base_url"`
|
||||||
Username string // basic auth; empty = anonymous (won't work with deny-all)
|
|
||||||
Password string // basic auth
|
// Topic — where maven publishes. Required. All maven notifications land
|
||||||
Timeout time.Duration // per-request; 0 = DefaultTimeout
|
// on this one topic; severity rides the Priority header, not the topic.
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
|
||||||
|
// Token — an ntfy access token, sent as a bearer. This is the preferred
|
||||||
|
// credential: ntfy scopes a token to a topic and to write-only, so a
|
||||||
|
// popped sink can push to this one topic and cannot read it back or
|
||||||
|
// touch another. Revoking it does not disturb a password anyone else
|
||||||
|
// uses. Mutually exclusive with Username.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
|
||||||
|
// Username, Password — basic auth, for a server that has no tokens.
|
||||||
|
// Empty username means no credential is sent at all, which a deny-all
|
||||||
|
// server rejects.
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-request; 0 = DefaultTimeout. A dead server must not hang
|
||||||
|
// the tick loop.
|
||||||
|
Timeout time.Duration `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultTimeout = 10 * time.Second
|
const DefaultTimeout = 10 * time.Second
|
||||||
@@ -59,6 +83,12 @@ func New(cfg Config) (*Sink, error) {
|
|||||||
if cfg.Topic == "" {
|
if cfg.Topic == "" {
|
||||||
return nil, fmt.Errorf("ntfysink: Topic is required")
|
return nil, fmt.Errorf("ntfysink: Topic is required")
|
||||||
}
|
}
|
||||||
|
// Refuse rather than pick. Two credentials configured means someone
|
||||||
|
// intended one of them, and guessing which would send the other nowhere
|
||||||
|
// and leave a working config that is not the one they wrote.
|
||||||
|
if cfg.Token != "" && cfg.Username != "" {
|
||||||
|
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
|
||||||
|
}
|
||||||
to := cfg.Timeout
|
to := cfg.Timeout
|
||||||
if to == 0 {
|
if to == 0 {
|
||||||
to = DefaultTimeout
|
to = DefaultTimeout
|
||||||
@@ -84,7 +114,9 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Title", "maven")
|
req.Header.Set("Title", "maven")
|
||||||
req.Header.Set("Priority", priorityFor(d).String())
|
req.Header.Set("Priority", priorityFor(d).String())
|
||||||
if s.cfg.Username != "" {
|
if s.cfg.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.cfg.Token)
|
||||||
|
} else if s.cfg.Username != "" {
|
||||||
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
|
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,37 @@ func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSendSetsBearerToken — the deployed credential (V-649) is an ntfy access
|
||||||
|
// token scoped write-only to the maven topic, not a password. A token sent as
|
||||||
|
// basic auth is rejected by ntfy, so the header shape is the whole test.
|
||||||
|
func TestSendSetsBearerToken(t *testing.T) {
|
||||||
|
rs := newRecordingServer(t, 200, "")
|
||||||
|
srv := httptest.NewServer(rs.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven", Token: "tk_secret"})
|
||||||
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, _, auth, _, _ := rs.snapshot()
|
||||||
|
if auth != "Bearer tk_secret" {
|
||||||
|
t.Fatalf("auth: want 'Bearer tk_secret', got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRejectsBothCredentials — configuring a token and a username means one
|
||||||
|
// of them was meant and the other is a leftover. Picking either would leave a
|
||||||
|
// server that authenticates against a credential nobody wrote down.
|
||||||
|
func TestNewRejectsBothCredentials(t *testing.T) {
|
||||||
|
_, err := New(Config{BaseURL: "http://x", Topic: "maven", Token: "tk_x", Username: "maven"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New accepted both a token and a username")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not both") {
|
||||||
|
t.Errorf("error does not say which to fix: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendTitleIsMaven(t *testing.T) {
|
func TestSendTitleIsMaven(t *testing.T) {
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
|
|||||||
Reference in New Issue
Block a user