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:
103
internal/apprise/apprise.go
Normal file
103
internal/apprise/apprise.go
Normal 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
|
||||
}
|
||||
91
internal/apprise/apprise_test.go
Normal file
91
internal/apprise/apprise_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user