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:
260
internal/hub/ask.go
Normal file
260
internal/hub/ask.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// ImportantCategories are the categories that mean "needs Sam's attention". They
|
||||
// come from AGENTS.md §13.2 (action, important).
|
||||
var ImportantCategories = []string{"Action", "Important"}
|
||||
|
||||
// answerer decisions. /ask is a small read-only question-answering surface, not
|
||||
// an LLM call: it must answer in under three seconds and never guess.
|
||||
type Answerer struct {
|
||||
Reader Reader
|
||||
// Now is injectable so expiry timestamps are deterministic in tests.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewAnswerer builds an Answerer over a read-only Reader.
|
||||
func NewAnswerer(r Reader) *Answerer {
|
||||
return &Answerer{Reader: r, Now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// now returns the current time, tolerating a nil Now (as in a zero Answerer).
|
||||
func (a *Answerer) now() time.Time {
|
||||
if a.Now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return a.Now()
|
||||
}
|
||||
|
||||
// Answer returns a short, speakable answer for one question. It never writes
|
||||
// state. A question it cannot answer returns answer=null plus a reason.
|
||||
func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
|
||||
now := a.now()
|
||||
|
||||
asker := strings.ToLower(strings.TrimSpace(req.Asker))
|
||||
person := strings.ToLower(strings.TrimSpace(req.Person))
|
||||
if person == "" {
|
||||
person = asker
|
||||
}
|
||||
switch {
|
||||
case asker == "":
|
||||
return cannotAnswer("no asker was given")
|
||||
case asker != "sam":
|
||||
return cannotAnswer("I only answer for Sam")
|
||||
case person != "sam":
|
||||
return cannotAnswer("no data for that person")
|
||||
}
|
||||
|
||||
q := normalizeQuestion(req.Question)
|
||||
if q == "" {
|
||||
return cannotAnswer("no question was given")
|
||||
}
|
||||
|
||||
// The service does not track read state; say so instead of inventing one.
|
||||
if containsAny(q, "unread", "not read", "mark as read") {
|
||||
return cannotAnswer("I do not track read or unread mail")
|
||||
}
|
||||
|
||||
counts, err := a.Reader.CountByCategory(ctx, "inbox")
|
||||
if err != nil {
|
||||
return cannotAnswer("the inbox could not be read")
|
||||
}
|
||||
byCategory := map[string]int{}
|
||||
total := 0
|
||||
for _, c := range counts {
|
||||
byCategory[strings.ToLower(strings.TrimSpace(c.Category))] = c.Count
|
||||
total += c.Count
|
||||
}
|
||||
|
||||
switch {
|
||||
case containsAny(q, "important", "urgent", "needs attention", "need attention",
|
||||
"needs a decision", "needs a reply", "needs action", "action required",
|
||||
"needs doing", "follow up", "follow-up", "to do", "todo"):
|
||||
return a.answerImportant(ctx, now, byCategory)
|
||||
case containsAny(q, "spam", "junk"):
|
||||
return a.answerSpam(ctx, now, byCategory)
|
||||
case containsAny(q, "newsletter", "unsubscribe", "subscription", "marketing", "digest"):
|
||||
return a.answerNewsletter(now, byCategory)
|
||||
case containsAny(q, "how many", "how much", "count", "number of"):
|
||||
return a.answerCount(now, total, counts)
|
||||
case containsAny(q, "summary", "overview", "what's in", "whats in", "what is in",
|
||||
"anything new", "catch me up", "update me", "anything in my inbox", "anything"):
|
||||
return a.answerSummary(now, total, byCategory)
|
||||
default:
|
||||
return cannotAnswer("I can answer questions about important mail, spam, newsletters, and inbox counts")
|
||||
}
|
||||
}
|
||||
|
||||
// answerImportant reports how many inbox messages need attention.
|
||||
func (a *Answerer) answerImportant(ctx context.Context, now time.Time, byCategory map[string]int) AskResponse {
|
||||
n := byCategory["action"] + byCategory["important"]
|
||||
resp := AskResponse{Expires: expiresIn(now, 10*time.Minute)}
|
||||
if n == 0 {
|
||||
resp.Answer = answerText("Nothing important is waiting in your inbox.")
|
||||
resp.Data = map[string]any{"count": 0}
|
||||
return resp
|
||||
}
|
||||
|
||||
items, err := a.Reader.MessagesByCategory(ctx, "inbox", ImportantCategories, 6)
|
||||
if err != nil {
|
||||
return cannotAnswer("the inbox could not be read")
|
||||
}
|
||||
|
||||
switch {
|
||||
case n == 1 && len(items) > 0:
|
||||
resp.Answer = answerText(fmt.Sprintf("One email needs your attention: %q from %s.",
|
||||
truncate(items[0].Subject, 60), senderName(items[0].FromAddr)))
|
||||
case len(items) > 0:
|
||||
resp.Answer = answerText(fmt.Sprintf("%d emails need your attention, including %q from %s.",
|
||||
n, truncate(items[0].Subject, 60), senderName(items[0].FromAddr)))
|
||||
default:
|
||||
resp.Answer = answerText(fmt.Sprintf("%d emails need your attention.", n))
|
||||
}
|
||||
resp.Data = map[string]any{
|
||||
"count": n,
|
||||
"messages": messageData(items),
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// answerSpam reports spam. Spam is always recommended, never auto-deleted
|
||||
// (AGENTS.md §10.5), so the wording stays neutral.
|
||||
func (a *Answerer) answerSpam(ctx context.Context, now time.Time, byCategory map[string]int) AskResponse {
|
||||
n := byCategory["spam"]
|
||||
resp := AskResponse{Expires: expiresIn(now, 10*time.Minute)}
|
||||
if n == 0 {
|
||||
resp.Answer = answerText("There is no spam in your inbox right now.")
|
||||
resp.Data = map[string]any{"count": 0}
|
||||
return resp
|
||||
}
|
||||
items, err := a.Reader.MessagesByCategory(ctx, "inbox", []string{"Spam"}, 6)
|
||||
if err != nil {
|
||||
return cannotAnswer("the inbox could not be read")
|
||||
}
|
||||
resp.Answer = answerText(fmt.Sprintf("%d spam emails are in your inbox.", n))
|
||||
resp.Data = map[string]any{"count": n, "messages": messageData(items)}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (a *Answerer) answerNewsletter(now time.Time, byCategory map[string]int) AskResponse {
|
||||
n := byCategory["newsletter"]
|
||||
answer := fmt.Sprintf("Your inbox has %d newsletters.", n)
|
||||
if n == 1 {
|
||||
answer = "Your inbox has 1 newsletter."
|
||||
}
|
||||
return AskResponse{
|
||||
Answer: answerText(answer),
|
||||
Data: map[string]any{"count": n},
|
||||
Expires: expiresIn(now, 10*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Answerer) answerCount(now time.Time, total int, counts []store.CategoryCount) AskResponse {
|
||||
return AskResponse{
|
||||
Answer: answerText(fmt.Sprintf("Your inbox has %d emails.", total)),
|
||||
Data: map[string]any{"total": total, "by_category": categoryMap(counts)},
|
||||
Expires: expiresIn(now, 10*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Answerer) answerSummary(now time.Time, total int, byCategory map[string]int) AskResponse {
|
||||
needs := byCategory["action"] + byCategory["important"]
|
||||
news := byCategory["newsletter"]
|
||||
answer := fmt.Sprintf("Your inbox has %d emails: %d need attention and %d are newsletters.",
|
||||
total, needs, news)
|
||||
return AskResponse{
|
||||
Answer: answerText(answer),
|
||||
Data: map[string]any{
|
||||
"total": total,
|
||||
"needs_action": needs,
|
||||
"newsletters": news,
|
||||
"spam": byCategory["spam"],
|
||||
},
|
||||
Expires: expiresIn(now, 10*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// messageData projects a message list into the compact `data` payload the Hub
|
||||
// may ignore (ask-contract.md).
|
||||
func messageData(items []store.MessageListItem) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
out = append(out, map[string]any{
|
||||
"id": m.ID,
|
||||
"from": m.FromAddr,
|
||||
"subject": m.Subject,
|
||||
"category": m.Category,
|
||||
"importance": m.Importance,
|
||||
"action": m.Action,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// categoryMap turns the count rows into a JSON-friendly map keyed by the
|
||||
// category name. The unclassified bucket uses the name "unclassified".
|
||||
func categoryMap(counts []store.CategoryCount) map[string]int {
|
||||
out := make(map[string]int, len(counts))
|
||||
for _, c := range counts {
|
||||
name := strings.ToLower(strings.TrimSpace(c.Category))
|
||||
if name == "" {
|
||||
name = "unclassified"
|
||||
}
|
||||
out[name] = c.Count
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeQuestion lowercases and collapses whitespace so intent matching is
|
||||
// stable.
|
||||
func normalizeQuestion(q string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(q)), " ")
|
||||
}
|
||||
|
||||
func containsAny(q string, subs ...string) bool {
|
||||
for _, s := range subs {
|
||||
if strings.Contains(q, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// senderName extracts a display name from a From header, falling back to the
|
||||
// address or the raw value.
|
||||
func senderName(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if i := strings.Index(addr, "<"); i > 0 {
|
||||
name := strings.Trim(strings.TrimSpace(addr[:i]), `"'`)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if i := strings.Index(addr, "<"); i >= 0 {
|
||||
if j := strings.Index(addr[i:], ">"); j > i {
|
||||
return strings.TrimSpace(addr[i+1 : i+j])
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// truncate shortens s to at most n runes so a spoken answer stays short.
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "(no subject)"
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:n-1])) + "…"
|
||||
}
|
||||
224
internal/hub/ask_test.go
Normal file
224
internal/hub/ask_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// fakeReader is a read-only in-memory Reader. It deliberately has no write
|
||||
// method: /ask cannot change state through this interface.
|
||||
type fakeReader struct {
|
||||
counts []store.CategoryCount
|
||||
items []store.MessageListItem
|
||||
countErr error
|
||||
msgErr error
|
||||
countCalls int
|
||||
msgCalls int
|
||||
}
|
||||
|
||||
func (f *fakeReader) CountByCategory(_ context.Context, _ string) ([]store.CategoryCount, error) {
|
||||
f.countCalls++
|
||||
return f.counts, f.countErr
|
||||
}
|
||||
|
||||
func (f *fakeReader) MessagesByCategory(_ context.Context, _ string, _ []string, _ int) ([]store.MessageListItem, error) {
|
||||
f.msgCalls++
|
||||
return f.items, f.msgErr
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Date(2026, 10, 11, 8, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func newTestAnswerer(r Reader) *Answerer {
|
||||
a := NewAnswerer(r)
|
||||
a.Now = fixedNow
|
||||
return a
|
||||
}
|
||||
|
||||
func TestAnswerImportant(t *testing.T) {
|
||||
r := &fakeReader{
|
||||
counts: []store.CategoryCount{
|
||||
{Category: "Action", Count: 2},
|
||||
{Category: "Important", Count: 1},
|
||||
{Category: "Newsletter", Count: 40},
|
||||
},
|
||||
items: []store.MessageListItem{{
|
||||
Message: store.Message{ID: 96, FromAddr: "Google <no-reply@accounts.google.com>", Subject: "Security alert"},
|
||||
Category: "Action",
|
||||
Importance: "high",
|
||||
}},
|
||||
}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
|
||||
Question: "anything important in my inbox?",
|
||||
Asker: "sam",
|
||||
})
|
||||
|
||||
if resp.Answer == nil {
|
||||
t.Fatalf("Answer = nil, reason = %q", resp.Reason)
|
||||
}
|
||||
if !strings.Contains(*resp.Answer, "3 emails need your attention") {
|
||||
t.Errorf("answer = %q, want it to mention 3 emails", *resp.Answer)
|
||||
}
|
||||
if !strings.Contains(*resp.Answer, "Security alert") {
|
||||
t.Errorf("answer = %q, want the top subject", *resp.Answer)
|
||||
}
|
||||
if got := resp.Data["count"]; got != 3 {
|
||||
t.Errorf("data.count = %v, want 3", got)
|
||||
}
|
||||
if resp.Expires == "" {
|
||||
t.Error("answered response has no expiry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerImportantNone(t *testing.T) {
|
||||
r := &fakeReader{counts: []store.CategoryCount{{Category: "Info", Count: 4}}}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
|
||||
Question: "is anything urgent?",
|
||||
Asker: "sam",
|
||||
})
|
||||
if resp.Answer == nil || !strings.Contains(*resp.Answer, "Nothing important") {
|
||||
t.Fatalf("answer = %v, want the empty-inbox message", resp.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerCannotAnswerForOtherPerson(t *testing.T) {
|
||||
r := &fakeReader{}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
|
||||
Question: "anything important?",
|
||||
Asker: "sam",
|
||||
Person: "harry",
|
||||
})
|
||||
if resp.Answer != nil {
|
||||
t.Fatalf("Answer = %v, want nil", *resp.Answer)
|
||||
}
|
||||
if !strings.Contains(resp.Reason, "no data") {
|
||||
t.Errorf("reason = %q", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerRequiresAsker(t *testing.T) {
|
||||
r := &fakeReader{}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{Question: "anything important?"})
|
||||
if resp.Answer != nil || resp.Reason == "" {
|
||||
t.Fatalf("answer = %v reason = %q, want a reason", resp.Answer, resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerDoesNotTrackUnread(t *testing.T) {
|
||||
r := &fakeReader{}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
|
||||
Question: "how many unread emails do I have?",
|
||||
Asker: "sam",
|
||||
})
|
||||
if resp.Answer != nil {
|
||||
t.Fatalf("Answer = %v, want nil", *resp.Answer)
|
||||
}
|
||||
if !strings.Contains(resp.Reason, "read") {
|
||||
t.Errorf("reason = %q, want it to mention read state", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerSpamAndNewsletter(t *testing.T) {
|
||||
r := &fakeReader{
|
||||
counts: []store.CategoryCount{
|
||||
{Category: "Spam", Count: 1},
|
||||
{Category: "Newsletter", Count: 12},
|
||||
},
|
||||
items: []store.MessageListItem{{
|
||||
Message: store.Message{ID: 5, FromAddr: "spammer@example.com", Subject: "Cheap pills"},
|
||||
Category: "Spam",
|
||||
}},
|
||||
}
|
||||
a := newTestAnswerer(r)
|
||||
spam := a.Answer(context.Background(), AskRequest{Question: "any spam?", Asker: "sam"})
|
||||
if spam.Answer == nil || !strings.Contains(*spam.Answer, "1 spam") {
|
||||
t.Errorf("spam answer = %v", spam.Answer)
|
||||
}
|
||||
news := a.Answer(context.Background(), AskRequest{Question: "how many newsletters?", Asker: "sam"})
|
||||
if news.Answer == nil || !strings.Contains(*news.Answer, "12 newsletters") {
|
||||
t.Errorf("newsletter answer = %v", news.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerCountAndSummary(t *testing.T) {
|
||||
r := &fakeReader{counts: []store.CategoryCount{
|
||||
{Category: "Action", Count: 19},
|
||||
{Category: "Important", Count: 1},
|
||||
{Category: "Newsletter", Count: 40},
|
||||
{Category: "Auto-reply", Count: 34},
|
||||
{Category: "Info", Count: 7},
|
||||
{Category: "Spam", Count: 1},
|
||||
}}
|
||||
a := newTestAnswerer(r)
|
||||
count := a.Answer(context.Background(), AskRequest{Question: "how many emails are in my inbox?", Asker: "sam"})
|
||||
if count.Answer == nil || !strings.Contains(*count.Answer, "102 emails") {
|
||||
t.Errorf("count answer = %v", count.Answer)
|
||||
}
|
||||
sum := a.Answer(context.Background(), AskRequest{Question: "give me an overview", Asker: "sam"})
|
||||
if sum.Answer == nil || !strings.Contains(*sum.Answer, "102 emails") {
|
||||
t.Errorf("summary answer = %v", sum.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerUnknownQuestion(t *testing.T) {
|
||||
r := &fakeReader{}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
|
||||
Question: "what time is it?",
|
||||
Asker: "sam",
|
||||
})
|
||||
if resp.Answer != nil || resp.Reason == "" {
|
||||
t.Fatalf("answer = %v reason = %q, want a cannot-answer reply", resp.Answer, resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerReadErrorIsReported(t *testing.T) {
|
||||
r := &fakeReader{countErr: errors.New("boom")}
|
||||
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{Question: "anything important?", Asker: "sam"})
|
||||
if resp.Answer != nil || !strings.Contains(resp.Reason, "could not be read") {
|
||||
t.Fatalf("answer = %v reason = %q", resp.Answer, resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Google <no-reply@accounts.google.com>": "Google",
|
||||
`"api@minimaxi.com" <api@minimaxi.com>`: "api@minimaxi.com",
|
||||
"<no-reply@mail.minimax.io>": "no-reply@mail.minimax.io",
|
||||
"plain@example.com": "plain@example.com",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := senderName(in); got != want {
|
||||
t.Errorf("senderName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCapabilities(t *testing.T) {
|
||||
c := DefaultCapabilities()
|
||||
if c.Project != ProjectName {
|
||||
t.Errorf("project = %q, want %q", c.Project, ProjectName)
|
||||
}
|
||||
for _, topic := range []string{"email", "inbox", "mail"} {
|
||||
if !containsString(c.Topics, topic) {
|
||||
t.Errorf("topics missing %q", topic)
|
||||
}
|
||||
}
|
||||
if containsString(c.Topics, "unread") {
|
||||
t.Error("topics must not claim read/unread support")
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
93
internal/hub/hub.go
Normal file
93
internal/hub/hub.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package hub implements the project side of the Hub contract
|
||||
// (central_ai_llm_master/docs/ask-contract.md) and the outbound notify call.
|
||||
//
|
||||
// The contract is one-directional by design: the Hub asks a project about its
|
||||
// own data and the project answers. A project never learns the Hub's data model
|
||||
// and the Hub never learns the project's. The project endpoints are
|
||||
// GET /capabilities and POST /ask (read only); POST /notify is the
|
||||
// project-to-Hub direction.
|
||||
//
|
||||
// Phase 7 (FR-12). Answers are computed from SQLite; /ask performs no writes.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
// ProjectName is the value the Hub matches in routing.md.
|
||||
const ProjectName = "email_agent"
|
||||
|
||||
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
|
||||
// question to this project. Keep the topic list short and plain; it holds no
|
||||
// private data.
|
||||
type Capabilities struct {
|
||||
Project string `json:"project"`
|
||||
Topics []string `json:"topics"`
|
||||
People []string `json:"people"`
|
||||
}
|
||||
|
||||
// DefaultCapabilities returns the topics this service can answer. "email",
|
||||
// "inbox", "mail", and "message" match the routing.md topic map. Read/unread is
|
||||
// deliberately absent: the service does not track read state.
|
||||
func DefaultCapabilities() Capabilities {
|
||||
return Capabilities{
|
||||
Project: ProjectName,
|
||||
Topics: []string{"email", "inbox", "mail", "message"},
|
||||
People: []string{"sam"},
|
||||
}
|
||||
}
|
||||
|
||||
// AskRequest is the POST /ask body (ask-contract.md).
|
||||
type AskRequest struct {
|
||||
Question string `json:"question"`
|
||||
Asker string `json:"asker"`
|
||||
Person string `json:"person,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
|
||||
// answer and Reason explains why. The shape is frozen by the contract.
|
||||
type AskResponse struct {
|
||||
Answer *string `json:"answer"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Expires string `json:"expires,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// answerText is a small helper so the pointer handling stays in one place.
|
||||
func answerText(s string) *string { return &s }
|
||||
|
||||
// cannotAnswer builds the contract's "cannot answer" reply.
|
||||
func cannotAnswer(reason string) AskResponse {
|
||||
return AskResponse{Answer: nil, Reason: reason}
|
||||
}
|
||||
|
||||
// expiresIn formats an RFC3339 expiry used by data-bearing answers. An inbox
|
||||
// answer goes stale quickly because mail arrives and is triaged.
|
||||
func expiresIn(now time.Time, d time.Duration) string {
|
||||
return now.UTC().Add(d).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// NotifyRequest is the project-to-Hub POST /notify body (ask-contract.md). The
|
||||
// Hub may use it to speak or notify.
|
||||
type NotifyRequest struct {
|
||||
From string `json:"from"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Person string `json:"person,omitempty"`
|
||||
}
|
||||
|
||||
// jsonBody marshals a notify payload, kept small and deterministic for tests.
|
||||
func jsonBody(v any) ([]byte, error) { return json.Marshal(v) }
|
||||
|
||||
// Reader is the read-only data surface the answerer uses. *store.Store
|
||||
// satisfies it; tests can supply a fake. Keeping it an interface makes the
|
||||
// read-only guarantee explicit and easy to assert.
|
||||
type Reader interface {
|
||||
CountByCategory(ctx context.Context, folder string) ([]store.CategoryCount, error)
|
||||
MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]store.MessageListItem, error)
|
||||
}
|
||||
104
internal/hub/notify.go
Normal file
104
internal/hub/notify.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
87
internal/hub/notify_test.go
Normal file
87
internal/hub/notify_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNotifyPostsContractPayload(t *testing.T) {
|
||||
var (
|
||||
gotReq NotifyRequest
|
||||
gotAuth string
|
||||
hits int
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
if r.URL.Path != "/notify" {
|
||||
t.Errorf("path = %q, want /notify", r.URL.Path)
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Errorf("decode body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n := NewNotifier(srv.URL, "hub-token", time.Second, nil)
|
||||
if err := n.NotifyImportantCount(context.Background(), 3); err != nil {
|
||||
t.Fatalf("NotifyImportantCount() error = %v", err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("server hits = %d, want 1", hits)
|
||||
}
|
||||
if gotAuth != "Bearer hub-token" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if gotReq.From != ProjectName {
|
||||
t.Errorf("from = %q, want %q", gotReq.From, ProjectName)
|
||||
}
|
||||
if gotReq.Level != "important" {
|
||||
t.Errorf("level = %q, want important", gotReq.Level)
|
||||
}
|
||||
if gotReq.Person != "sam" {
|
||||
t.Errorf("person = %q, want sam", gotReq.Person)
|
||||
}
|
||||
if gotReq.Message != "3 emails need a decision." {
|
||||
t.Errorf("message = %q", gotReq.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyImportantCountZeroIsNoop(t *testing.T) {
|
||||
hits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ }))
|
||||
defer srv.Close()
|
||||
n := NewNotifier(srv.URL, "", time.Second, nil)
|
||||
if err := n.NotifyImportantCount(context.Background(), 0); err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("server hits = %d, want 0", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyErrorStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
n := NewNotifier(srv.URL, "", time.Second, nil)
|
||||
if err := n.NotifySummaryReady(context.Background(), "x"); err == nil {
|
||||
t.Fatal("expected an error for a 404 reply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierNotConfigured(t *testing.T) {
|
||||
n := NewNotifier("", "", time.Second, nil)
|
||||
if n.Configured() {
|
||||
t.Error("empty notifier reports configured")
|
||||
}
|
||||
if err := n.NotifySummaryReady(context.Background(), "x"); err == nil {
|
||||
t.Fatal("expected an error when the notifier is not configured")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user