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

View File

@@ -13,6 +13,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
// Defaults for the service. Port 8790 is recorded in AGENTS.md §9.
@@ -49,6 +50,16 @@ const (
// apps use the same API endpoint. OMNIROUTE_BASE_URL overrides it.
DefaultOmniRouteBaseURL = "http://192.168.20.13:20129/v1"
DefaultOmniRouteModel = "auto/best-chat"
// Hub defaults (Phase 7, FR-12). The central Hub runs on .13:8788. All
// non-JEV model calls and project notifications leave through it.
DefaultHubBaseURL = "http://192.168.20.13:8788"
DefaultHubNotifyTimout = 5 * time.Second
// Apprise defaults (FR-10). The house Apprise API runs on .35:8210 and
// fans out to email/NTFY/Telegram. SUMMARY_TO is the summary recipient.
DefaultAppriseURL = "http://192.168.20.35:8210"
DefaultSummaryTo = "samuelrolfe@gmail.com"
)
// Config is the fully resolved runtime configuration.
@@ -121,8 +132,27 @@ type Config struct {
// SMTPPort is the Gmail submission port (default 587, STARTTLS).
SMTPPort int
// HubToken authenticates the read-only Hub API. Secret.
// HubToken authenticates the read-only Hub API (FR-12). Secret. The Hub
// sends it as a bearer token; this service never trusts an unsigned request.
HubToken string
// HubBaseURL is the base URL of the central Hub, used by the outbound
// POST /notify path (ask-contract.md). Not secret.
HubBaseURL string
// HubNotifyTimeout bounds one outbound notify call.
HubNotifyTimeout time.Duration
// Apprise (FR-10): the daily summary is delivered by email through Apprise.
// AppriseURL is the Apprise API base URL (default the house instance on
// .35). AppriseKey names a stored Apprise configuration. AppriseURLs is the
// stateless fallback used when no key is set. AppriseToken is an optional
// bearer token for a protected Apprise API. All values come from the host
// environment; none are committed.
AppriseURL string
AppriseKey string
AppriseURLs []string
AppriseToken string
// SummaryTo is the recipient address for the daily summary email.
SummaryTo string
}
// Load reads configuration from the environment, applying defaults.
@@ -142,7 +172,14 @@ func Load() (*Config, error) {
GmailUser: os.Getenv("GMAIL_USER"),
GmailAppPassword: os.Getenv("GMAIL_APP_PASSWORD"),
HubToken: os.Getenv("HUB_TOKEN"),
HubBaseURL: strings.TrimRight(getenvDefault("HUB_BASE_URL", DefaultHubBaseURL), "/"),
AppriseURL: strings.TrimRight(getenvDefault("APPRISE_URL", DefaultAppriseURL), "/"),
AppriseKey: strings.TrimSpace(os.Getenv("APPRISE_KEY")),
AppriseURLs: splitList(os.Getenv("APPRISE_URLS")),
AppriseToken: os.Getenv("APPRISE_TOKEN"),
SummaryTo: getenvDefault("EMAIL_AGENT_SUMMARY_TO", DefaultSummaryTo),
}
c.HubNotifyTimeout = durationSetting("HUB_NOTIFY_TIMEOUT", DefaultHubNotifyTimout)
imapPort, err := resolveIMAPPort()
if err != nil {
@@ -288,3 +325,37 @@ func getenvDefault(key, def string) string {
}
return def
}
// splitList parses a comma-separated environment value, trimming blanks.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// durationSetting reads a Go duration setting, falling back to def when unset
// or malformed.
func durationSetting(key string, def time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return def
}
return d
}
// HubConfigured reports whether the Hub API can serve authenticated requests.
func (c *Config) HubConfigured() bool { return strings.TrimSpace(c.HubToken) != "" }
// AppriseConfigured reports whether a daily summary can be delivered.
func (c *Config) AppriseConfigured() bool {
return strings.TrimSpace(c.AppriseURL) != "" &&
(strings.TrimSpace(c.AppriseKey) != "" || len(c.AppriseURLs) > 0)
}

View File

@@ -109,3 +109,55 @@ func TestLoadInvalidEnv(t *testing.T) {
t.Fatal("Load() error = nil, want error for invalid env")
}
}
func TestHubAndAppriseConfig(t *testing.T) {
for _, k := range []string{"HUB_TOKEN", "HUB_BASE_URL", "APPRISE_URL", "APPRISE_KEY", "APPRISE_URLS", "EMAIL_AGENT_SUMMARY_TO", "HUB_NOTIFY_TIMEOUT"} {
t.Setenv(k, "")
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.HubConfigured() {
t.Error("HubConfigured() = true with no HUB_TOKEN")
}
if cfg.HubBaseURL != DefaultHubBaseURL {
t.Errorf("HubBaseURL = %q, want %q", cfg.HubBaseURL, DefaultHubBaseURL)
}
if cfg.AppriseURL != DefaultAppriseURL {
t.Errorf("AppriseURL = %q, want %q", cfg.AppriseURL, DefaultAppriseURL)
}
if cfg.SummaryTo != DefaultSummaryTo {
t.Errorf("SummaryTo = %q, want %q", cfg.SummaryTo, DefaultSummaryTo)
}
if cfg.AppriseConfigured() {
t.Error("AppriseConfigured() = true with no key or URLs")
}
t.Setenv("HUB_TOKEN", "hub-secret")
t.Setenv("APPRISE_KEY", "email-summary")
cfg, err = Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.HubConfigured() {
t.Error("HubConfigured() = false with HUB_TOKEN set")
}
if !cfg.AppriseConfigured() {
t.Error("AppriseConfigured() = false with a key set")
}
// A stateless URL list also configures Apprise.
t.Setenv("APPRISE_KEY", "")
t.Setenv("APPRISE_URLS", "mailto://a:b@example.com, ntfy://topic")
cfg, err = Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.AppriseConfigured() {
t.Error("AppriseConfigured() = false with APPRISE_URLS set")
}
if len(cfg.AppriseURLs) != 2 {
t.Errorf("AppriseURLs = %v, want 2 entries", cfg.AppriseURLs)
}
}