- 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>
99 lines
2.9 KiB
Go
99 lines
2.9 KiB
Go
// Hub API for Phase 7 (FR-12).
|
|
//
|
|
// These are the project side of the frozen contract in
|
|
// central_ai_llm_master/docs/ask-contract.md:
|
|
//
|
|
// GET /capabilities -> the topics this project answers (Hub routing)
|
|
// POST /ask -> answer a question about this project's data
|
|
//
|
|
// Both endpoints require the shared bearer token. /ask is read-only: it calls
|
|
// only store read methods and writes nothing, including no actions_log row.
|
|
// This is a deliberate, testable property (see TestAskWritesNothing).
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/hub"
|
|
)
|
|
|
|
// hubAuthRequired reports whether the Hub API has a token configured. With no
|
|
// token the endpoints refuse to serve rather than trust an unsigned request.
|
|
func (s *Server) hubAuthRequired(w http.ResponseWriter) bool {
|
|
if !s.cfg.HubConfigured() {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
|
"answer": nil,
|
|
"reason": "the Hub API is not configured",
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// requireHub enforces the project bearer token on the Hub endpoints.
|
|
func (s *Server) requireHub(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.hubAuthRequired(w) {
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(bearerToken(r)), []byte(s.cfg.HubToken)) != 1 {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]any{
|
|
"answer": nil,
|
|
"reason": "unauthorized",
|
|
})
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// bearerToken reads the project token from the Authorization header.
|
|
func bearerToken(r *http.Request) string {
|
|
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
if h == "" {
|
|
return ""
|
|
}
|
|
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
|
|
return strings.TrimSpace(h[7:])
|
|
}
|
|
return h
|
|
}
|
|
|
|
// handleCapabilities returns the routing topics for the Hub. It holds no
|
|
// private data.
|
|
func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, hub.DefaultCapabilities())
|
|
}
|
|
|
|
// handleAsk answers one question from this project's read-only data. The reply
|
|
// is always HTTP 200 with either an answer or a reason, so the Hub can relay a
|
|
// "cannot answer" without treating the project as down.
|
|
func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
var req hub.AskRequest
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
|
if err := dec.Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusOK, hub.AskResponse{
|
|
Answer: nil,
|
|
Reason: "the request body is not valid JSON",
|
|
})
|
|
return
|
|
}
|
|
|
|
resp := s.answerer.Answer(ctx, req)
|
|
s.logger.Debug("hub ask",
|
|
"asker", req.Asker,
|
|
"person", req.Person,
|
|
"source", req.Source,
|
|
"answered", resp.Answer != nil,
|
|
)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|