- 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>
104 lines
3.0 KiB
Go
104 lines
3.0 KiB
Go
// 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
|
|
}
|