Phase 7: daily summary and read-only Hub API (AUD-73)

- internal/hub: project side of the frozen ask-contract. GET /capabilities,
  read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify
  client.
- internal/apprise: daily-summary delivery through the house Apprise API.
- internal/summary: deterministic plain-text daily summary builder.
- internal/store: CountByCategory, MessagesByCategory, and summary CRUD.
- internal/server: /capabilities and /ask (bearer-token protected), plus the
  Reports screen (/reports, build, send, delete).
- main.go: -summary, -summary-send, and -notify CLI entry points for n8n.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 22:38:49 +11:00
parent c4213ebed1
commit a349b42b1f
24 changed files with 2454 additions and 1 deletions

103
internal/apprise/apprise.go Normal file
View File

@@ -0,0 +1,103 @@
// Package apprise delivers notifications through the house Apprise API.
//
// Apprise is the shared notification gateway (AGENTS.md §8). The Email Agent
// uses it only to deliver the daily summary by email (FR-10). The client speaks
// the standard Apprise API server shape:
//
// POST {base}/notify/{key} JSON {"title","body","type"}
// POST {base}/notify JSON {"urls","title","body","type"} (stateless)
//
// A key names a stored Apprise configuration. When no key is set the client
// uses the stateless form with an explicit list of Apprise URLs.
package apprise
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Client posts a notification to one Apprise API server.
type Client struct {
BaseURL string
// Key names a stored Apprise configuration. Empty means stateless mode.
Key string
// URLs is the stateless destination list, used when Key is empty.
URLs []string
// Token is sent as a bearer token when the Apprise API is protected.
Token string
HTTP *http.Client
}
// New builds a client. baseURL is the Apprise API root, e.g.
// http://192.168.20.35:8210. A zero timeout defaults to ten seconds.
func New(baseURL, key string, urls []string, token string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Key: strings.TrimSpace(key),
URLs: urls,
Token: token,
HTTP: &http.Client{Timeout: timeout},
}
}
// Configured reports whether a delivery can be attempted.
func (c *Client) Configured() bool {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return false
}
return c.Key != "" || len(c.URLs) > 0
}
// notifyBody is the Apprise API request body. `type` selects the notification
// class; the daily summary is informational.
type notifyBody struct {
URLs []string `json:"urls,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Type string `json:"type,omitempty"`
}
// Send delivers one notification titled title with the given text body.
func (c *Client) Send(ctx context.Context, title, body string) error {
if !c.Configured() {
return fmt.Errorf("apprise is not configured")
}
payload := notifyBody{Title: title, Body: body, Type: "info"}
url := c.BaseURL + "/notify"
if c.Key != "" {
url += "/" + c.Key
} else {
payload.URLs = c.URLs
}
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode apprise request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("build apprise request: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("post apprise: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("post apprise: status %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,91 @@
package apprise
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type captured struct {
path string
auth string
body notifyBody
}
func TestSendWithKey(t *testing.T) {
var got captured
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.path = r.URL.Path
got.auth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&got.body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "email-summary", nil, "apprise-token", time.Second)
if err := c.Send(context.Background(), "Subject", "Body text"); err != nil {
t.Fatalf("Send() error = %v", err)
}
if got.path != "/notify/email-summary" {
t.Errorf("path = %q, want /notify/email-summary", got.path)
}
if got.auth != "Bearer apprise-token" {
t.Errorf("auth = %q", got.auth)
}
if got.body.Title != "Subject" || got.body.Body != "Body text" {
t.Errorf("body = %+v", got.body)
}
if got.body.Type != "info" {
t.Errorf("type = %q, want info", got.body.Type)
}
if len(got.body.URLs) != 0 {
t.Errorf("urls = %v, want none in key mode", got.body.URLs)
}
}
func TestSendStatelessURLs(t *testing.T) {
var got captured
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&got.body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "", []string{"mailto://user:pass@example.com"}, "", time.Second)
if err := c.Send(context.Background(), "T", "B"); err != nil {
t.Fatalf("Send() error = %v", err)
}
if got.path != "/notify" {
t.Errorf("path = %q, want /notify", got.path)
}
if len(got.body.URLs) != 1 || got.body.URLs[0] != "mailto://user:pass@example.com" {
t.Errorf("urls = %v", got.body.URLs)
}
}
func TestSendErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad", http.StatusUnauthorized)
}))
defer srv.Close()
c := New(srv.URL, "key", nil, "", time.Second)
if err := c.Send(context.Background(), "T", "B"); err == nil {
t.Fatal("expected an error for a 401 reply")
}
}
func TestConfigured(t *testing.T) {
if New("http://x", "", nil, "", time.Second).Configured() {
t.Error("keyless and urlless client reports configured")
}
if New("http://x", "k", nil, "", time.Second).Configured() != true {
t.Error("key client should report configured")
}
if New("http://x", "", []string{"mailto://a@b"}, "", time.Second).Configured() != true {
t.Error("url client should report configured")
}
}