Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s
CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
// Package chat sends notifications to the household's Google Chat space
|
||||
// through its incoming webhook, and keeps that webhook in chat_settings.
|
||||
//
|
||||
// The webhook URL carries its own key and token, so it is a secret: it is
|
||||
// sealed with secretbox in webhook_url_ct and is never returned, logged or
|
||||
// put in an error. Only its space label, a masked tail and the outcome of
|
||||
// the last test message are shown.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSpaceLabelChars bounds a space label.
|
||||
MaxSpaceLabelChars = 100
|
||||
// requestTimeout bounds one post to Google Chat.
|
||||
requestTimeout = 15 * time.Second
|
||||
// maxDrainBytes bounds how much of a Google Chat response is read.
|
||||
maxDrainBytes = 4096
|
||||
// replyOption threads a message under its threadKey, starting the
|
||||
// thread when there is none yet.
|
||||
replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
||||
)
|
||||
|
||||
// Test outcomes, as chat_settings.last_test_status stores them.
|
||||
const (
|
||||
TestOK = "ok"
|
||||
TestFailed = "failed"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured is returned when no webhook is stored.
|
||||
ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured")
|
||||
// ErrUnreachable is wrapped by the error of a post Google Chat did not
|
||||
// answer.
|
||||
ErrUnreachable = errors.New("chat: Google Chat did not answer")
|
||||
// ErrRefused is matched by the *RefusedError of a post Google Chat
|
||||
// answered with a status other than 2xx.
|
||||
ErrRefused = errors.New("chat: Google Chat refused the message")
|
||||
// ErrWebhookChanged is returned by Test when the webhook was replaced
|
||||
// while the test message was being sent; nothing is recorded.
|
||||
ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test")
|
||||
)
|
||||
|
||||
// Settings is the stored webhook as the API shows it: never the URL, only
|
||||
// its masked form. URLMasked is nil when no webhook is stored.
|
||||
type Settings struct {
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
// URLMasked is the webhook URL without its key and with only the last
|
||||
// few characters of its token.
|
||||
URLMasked *string `json:"url_masked"`
|
||||
// LastTestAt and LastTestStatus ("ok" or "failed") are nil until a
|
||||
// test message is sent to this webhook.
|
||||
LastTestAt *string `json:"last_test_at"`
|
||||
LastTestStatus *string `json:"last_test_status"`
|
||||
}
|
||||
|
||||
// TestResult is the outcome of a test message.
|
||||
type TestResult struct {
|
||||
// HTTPStatus is the status Google Chat answered.
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Settings
|
||||
}
|
||||
|
||||
// RefusedError is a post Google Chat answered with Status, not 2xx. It
|
||||
// matches ErrRefused.
|
||||
type RefusedError struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *RefusedError) Error() string {
|
||||
return fmt.Sprintf("%v with status %d", ErrRefused, e.Status)
|
||||
}
|
||||
|
||||
func (e *RefusedError) Unwrap() error { return ErrRefused }
|
||||
|
||||
// Retryable reports whether posting again may succeed: Google Chat was
|
||||
// busy (429) or failing (5xx), rather than refusing the message.
|
||||
func (e *RefusedError) Retryable() bool {
|
||||
return e.Status == http.StatusTooManyRequests || e.Status >= 500
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a webhook update, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every problem with a webhook update. It never
|
||||
// quotes the URL.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid Google Chat webhook: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service keeps the webhook and posts to it. It is the notify.Sender of the
|
||||
// chat channel.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button
|
||||
// links under.
|
||||
publicURL *url.URL
|
||||
client *http.Client
|
||||
// now is the clock settings are saved and tests recorded on; tests
|
||||
// replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing the webhook with box and
|
||||
// linking cards under publicURL. Requests go through transport
|
||||
// (http.DefaultTransport in production) and never follow a redirect.
|
||||
func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("chat: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("chat: nil secret box")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("chat: nil public URL")
|
||||
case transport == nil:
|
||||
return nil, errors.New("chat: nil transport")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
box: box,
|
||||
publicURL: publicURL,
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: requestTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the stored webhook's settings; every field is nil before one
|
||||
// is saved.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Settings{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(row, hook), nil
|
||||
}
|
||||
|
||||
// Put validates rawURL and spaceLabel and stores them, replacing any
|
||||
// webhook stored before and clearing its test outcome. Every problem is
|
||||
// reported in a *ValidationError.
|
||||
func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) {
|
||||
var problems []Problem
|
||||
hook, err := ParseWebhook(rawURL)
|
||||
if err != nil {
|
||||
problems = append(problems, Problem{"url", err.Error()})
|
||||
}
|
||||
label := strings.TrimSpace(spaceLabel)
|
||||
switch {
|
||||
case label == "":
|
||||
problems = append(problems, Problem{"space_label", "is required"})
|
||||
case utf8.RuneCountInString(label) > MaxSpaceLabelChars:
|
||||
problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)})
|
||||
case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }):
|
||||
problems = append(problems, Problem{"space_label", "must not contain control characters"})
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Settings{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
ct, err := s.box.Encrypt([]byte(hook.String()))
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: seal webhook: %w", err)
|
||||
}
|
||||
|
||||
var saved db.ChatSetting
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetChatSettings(ctx)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
now := s.timestamp()
|
||||
created := now
|
||||
if err == nil {
|
||||
created = row.CreatedAt
|
||||
}
|
||||
saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{
|
||||
WebhookUrlCt: ct,
|
||||
SpaceLabel: sql.NullString{String: label, Valid: true},
|
||||
CreatedAt: created,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: save chat settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(saved, hook), nil
|
||||
}
|
||||
|
||||
// Test posts TestText to the stored webhook, unthreaded, and records when
|
||||
// and whether Google Chat accepted it. A status Google Chat answers is a
|
||||
// TestResult, whatever it is; a request that gets no answer is recorded as
|
||||
// failed and returned as an error. A test cut short by ctx records nothing.
|
||||
func (s *Service) Test(ctx context.Context) (TestResult, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TestResult{}, ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
status, postErr := s.post(ctx, hook, message{Text: TestText}, "")
|
||||
if err := ctx.Err(); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
outcome := TestFailed
|
||||
if postErr == nil && status >= 200 && status < 300 {
|
||||
outcome = TestOK
|
||||
}
|
||||
at := s.timestamp()
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{
|
||||
LastTestAt: sql.NullString{String: at, Valid: true},
|
||||
LastTestStatus: sql.NullString{String: outcome, Valid: true},
|
||||
UpdatedAt: at,
|
||||
WebhookUrlCt: row.WebhookUrlCt,
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: record test: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return TestResult{}, ErrWebhookChanged
|
||||
}
|
||||
if postErr != nil {
|
||||
return TestResult{}, postErr
|
||||
}
|
||||
row.LastTestAt = sql.NullString{String: at, Valid: true}
|
||||
row.LastTestStatus = sql.NullString{String: outcome, Valid: true}
|
||||
row.UpdatedAt = at
|
||||
return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil
|
||||
}
|
||||
|
||||
// Send posts m to the stored webhook, threaded by the week of its payload,
|
||||
// and is the chat channel's notify.Sender. Google Chat answering 2xx is
|
||||
// success; 429 or 5xx, or no answer at all, is an error the outbox
|
||||
// retries; any other status, or no webhook stored, wraps
|
||||
// notify.ErrUndeliverable so the row fails at once.
|
||||
func (s *Service) Send(ctx context.Context, m notify.Message) error {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := s.messageOf(m.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
var thread string
|
||||
if m.Payload.Week != "" {
|
||||
week, err := store.ParseLocalDate(m.Payload.Week)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
thread = ThreadKey(week)
|
||||
}
|
||||
status, err := s.post(ctx, hook, msg, thread)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == http.StatusTooManyRequests || status >= 500:
|
||||
return fmt.Errorf("chat: Google Chat answered status %d", status)
|
||||
default:
|
||||
return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status)
|
||||
}
|
||||
}
|
||||
|
||||
// ThreadKey is the Chat thread of the week starting on week, such as
|
||||
// "mealquest-week-2026-W42": every message about a week goes in one thread.
|
||||
func ThreadKey(week store.LocalDate) string {
|
||||
return "mealquest-week-" + household.PeriodKey(week)
|
||||
}
|
||||
|
||||
// message is the body of a Google Chat webhook post.
|
||||
type message struct {
|
||||
Text string `json:"text"`
|
||||
CardsV2 []cardV2 `json:"cardsV2,omitempty"`
|
||||
}
|
||||
|
||||
type cardV2 struct {
|
||||
CardID string `json:"cardId"`
|
||||
Card card `json:"card"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Header cardHeader `json:"header"`
|
||||
Sections []cardSection `json:"sections"`
|
||||
}
|
||||
|
||||
type cardHeader struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
}
|
||||
|
||||
type cardSection struct {
|
||||
Widgets []cardWidget `json:"widgets"`
|
||||
}
|
||||
|
||||
type cardWidget struct {
|
||||
ButtonList buttonList `json:"buttonList"`
|
||||
}
|
||||
|
||||
type buttonList struct {
|
||||
Buttons []button `json:"buttons"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Text string `json:"text"`
|
||||
OnClick onClick `json:"onClick"`
|
||||
}
|
||||
|
||||
type onClick struct {
|
||||
OpenLink openLink `json:"openLink"`
|
||||
}
|
||||
|
||||
type openLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// messageOf is the Chat message of p: its Text and, for a plan or Supply
|
||||
// run, a card whose "Open" button links to p.URL under the public URL.
|
||||
func (s *Service) messageOf(p notify.Payload) (message, error) {
|
||||
msg := message{Text: Text(p)}
|
||||
if !carded[p.Event] {
|
||||
return msg, nil
|
||||
}
|
||||
c, err := s.openCard(string(p.Event), p.Title, p.Body, p.URL)
|
||||
if err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
msg.CardsV2 = []cardV2{c}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// openCard is a card headed title and subtitle whose "Open" button links
|
||||
// to the app path under the public URL.
|
||||
func (s *Service) openCard(id, title, subtitle, path string) (cardV2, error) {
|
||||
ref, err := url.Parse(path)
|
||||
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(path, "/") {
|
||||
return cardV2{}, fmt.Errorf("payload url %q is not an app path", path)
|
||||
}
|
||||
link := s.publicURL.ResolveReference(ref)
|
||||
return cardV2{
|
||||
CardID: id,
|
||||
Card: card{
|
||||
Header: cardHeader{Title: title, Subtitle: subtitle},
|
||||
Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{
|
||||
Text: "Open",
|
||||
OnClick: onClick{OpenLink: openLink{URL: link.String()}},
|
||||
}}}}}}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PostSupplyRun posts the Supply run of the week starting on week, named
|
||||
// name, with groups, to the stored webhook in the week's thread: its
|
||||
// SupplyRunText and a card whose "Open" button links to that week's Supply
|
||||
// run. No webhook stored is ErrNotConfigured; a post Google Chat does not
|
||||
// answer wraps ErrUnreachable, and one it answers with a status other than
|
||||
// 2xx is a *RefusedError.
|
||||
func (s *Service) PostSupplyRun(ctx context.Context, week store.LocalDate, name string, groups []SupplyRunGroup) error {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode()
|
||||
c, err := s.openCard("supply_run", "Supply run · "+name, "", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := s.post(ctx, hook, message{Text: SupplyRunText(name, groups), CardsV2: []cardV2{c}}, ThreadKey(week))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return &RefusedError{Status: status}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// post sends msg to hook, in thread when it is not empty, and returns the
|
||||
// status Google Chat answered. Its error never quotes the webhook URL.
|
||||
func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("chat: encode message: %w", err)
|
||||
}
|
||||
target, err := url.Parse(hook.String())
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: stored webhook URL does not parse")
|
||||
}
|
||||
if thread != "" {
|
||||
q := target.Query()
|
||||
q.Set("threadKey", thread)
|
||||
q.Set("messageReplyOption", replyOption)
|
||||
target.RawQuery = q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: build request to the webhook")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
// A *url.Error quotes the request URL, the webhook; keep only its
|
||||
// cause.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err)
|
||||
}
|
||||
return 0, ErrUnreachable
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// The status is the outcome; the body is read only so the connection
|
||||
// can be reused.
|
||||
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// open decrypts and validates the stored webhook. Its error never quotes
|
||||
// the URL.
|
||||
func (s *Service) open(row db.ChatSetting) (Webhook, error) {
|
||||
raw, err := s.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err)
|
||||
}
|
||||
hook, err := ParseWebhook(string(raw))
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err)
|
||||
}
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func (s *Service) timestamp() string {
|
||||
return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
|
||||
func settingsFrom(row db.ChatSetting, hook Webhook) Settings {
|
||||
masked := hook.Masked()
|
||||
return Settings{
|
||||
SpaceLabel: nullString(row.SpaceLabel),
|
||||
URLMasked: &masked,
|
||||
LastTestAt: nullString(row.LastTestAt),
|
||||
LastTestStatus: nullString(row.LastTestStatus),
|
||||
}
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
Reference in New Issue
Block a user