Add Google Chat webhook sender and settings API (Feature 19)
CI / ci (push) Successful in 6m38s
CI / ci (pull_request) Successful in 5m44s

internal/chat keeps the incoming webhook in chat_settings, sealed with
secretbox, and is the notify sender of the chat channel.

- PUT /api/v1/settings/chat-webhook {url, space_label} accepts only
  https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…;
  GET answers the space label, a masked URL tail and the last test outcome.
- POST /api/v1/settings/chat-webhook/test posts "MealQuest · test message",
  answers Google Chat's HTTP status and records last_test_at/status.
- Notifications post {text}, plus cardsV2 with an "Open" button under
  MEALQUEST_PUBLIC_URL for plan_ready and list_ready, threaded by
  threadKey=mealquest-week-YYYY-Www with
  REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD. 429 and 5xx are retried; any other
  non-2xx fails the row at once through the new notify.ErrUndeliverable.
- One place builds every event's title, body and Chat text.

The webhook URL never appears in a response, log line or error; tests
against an httptest Chat endpoint scan both.

Maestro task 919e9422-3a41-4f1f-8967-77da17f5f9f3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:58:36 -04:00
co-authored by Claude Opus 5.5
parent d5ab945ee7
commit 0e88ce811e
29 changed files with 1948 additions and 28 deletions
+25
View File
@@ -422,6 +422,31 @@ func TestRetryThenSent(t *testing.T) {
}
}
func TestUndeliverableFailsAtOnce(t *testing.T) {
ctx := context.Background()
svc, st, _ := newTestService(t)
setPref(t, st, PlanReady, false, true)
sender := &fakeSender{fail: fmt.Errorf("%w: chat refused the message (status 400)", ErrUndeliverable)}
if err := svc.Register(ChannelChat, sender); err != nil {
t.Fatal(err)
}
const key = "plan_ready:2026-10-12"
if err := svc.Emit(ctx, planReady(key)); err != nil {
t.Fatal(err)
}
wait, err := svc.deliverDue(ctx)
if err != nil {
t.Fatal(err)
}
r := onlyRow(t, st, key, ChannelChat)
if r.Status != "failed" || r.Attempts != 1 || r.LastError.String != sender.fail.Error() {
t.Fatalf("row = %+v, want failed after one attempt with the sender's error", r)
}
if wait != -1 || len(sender.messages()) != 1 {
t.Fatalf("wait = %v after %d deliveries, want nothing pending after one", wait, len(sender.messages()))
}
}
func TestGiveUpAfter24Hours(t *testing.T) {
ctx := context.Background()
svc, st, c := newTestService(t)