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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user