- 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>
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
// Outbound notify path (ask-contract.md, "Notify (project to Hub)").
|
|
//
|
|
// A project may tell the Hub something so the Hub can speak or notify. This is
|
|
// the one call this service makes into the Hub. It is best-effort: the Hub's
|
|
// /notify receiver is not part of its frozen v1 routes (docs/hub-api.md §12),
|
|
// so a failure is logged and never blocks the local work.
|
|
package hub
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Notifier posts project notifications to the Hub. The nil zero value is not
|
|
// usable; build one with NewNotifier.
|
|
type Notifier struct {
|
|
BaseURL string
|
|
Token string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// NewNotifier builds a notifier for the Hub at baseURL. timeout defaults to
|
|
// five seconds when it is zero or negative.
|
|
func NewNotifier(baseURL, token string, timeout time.Duration, httpClient *http.Client) *Notifier {
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: timeout}
|
|
}
|
|
return &Notifier{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
Token: token,
|
|
HTTP: httpClient,
|
|
}
|
|
}
|
|
|
|
// Configured reports whether the outbound notify path can be attempted.
|
|
func (n *Notifier) Configured() bool {
|
|
return n != nil && strings.TrimSpace(n.BaseURL) != ""
|
|
}
|
|
|
|
// Notify posts one notification. A nil notifier is a no-op so callers do not
|
|
// need to branch on configuration.
|
|
func (n *Notifier) Notify(ctx context.Context, req NotifyRequest) error {
|
|
if !n.Configured() {
|
|
return fmt.Errorf("hub notify is not configured")
|
|
}
|
|
if strings.TrimSpace(req.From) == "" {
|
|
req.From = ProjectName
|
|
}
|
|
body, err := jsonBody(req)
|
|
if err != nil {
|
|
return fmt.Errorf("encode notify: %w", err)
|
|
}
|
|
url := n.BaseURL + "/notify"
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("build notify request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
if n.Token != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+n.Token)
|
|
}
|
|
|
|
resp, err := n.HTTP.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("post notify: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("post notify: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NotifySummaryReady is the convenience call used after a daily summary is
|
|
// built.
|
|
func (n *Notifier) NotifySummaryReady(ctx context.Context, message string) error {
|
|
return n.Notify(ctx, NotifyRequest{
|
|
From: ProjectName,
|
|
Level: "info",
|
|
Message: message,
|
|
Person: "sam",
|
|
})
|
|
}
|
|
|
|
// NotifyImportantCount tells the Hub how many messages need attention. It is
|
|
// used by the daily summary job when the count is non-zero.
|
|
func (n *Notifier) NotifyImportantCount(ctx context.Context, n2 int) error {
|
|
if n2 <= 0 {
|
|
return nil
|
|
}
|
|
return n.Notify(ctx, NotifyRequest{
|
|
From: ProjectName,
|
|
Level: "important",
|
|
Message: fmt.Sprintf("%d emails need a decision.", n2),
|
|
Person: "sam",
|
|
})
|
|
}
|