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>
85 lines
3.3 KiB
Go
85 lines
3.3 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
|
)
|
|
|
|
// chatWebhookBody is the PUT /settings/chat-webhook body. Both fields are
|
|
// required.
|
|
type chatWebhookBody struct {
|
|
URL *string `json:"url"`
|
|
SpaceLabel *string `json:"space_label"`
|
|
}
|
|
|
|
// getChatWebhook answers the stored webhook's settings, which never hold
|
|
// its URL.
|
|
func getChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := svc.Get(r.Context())
|
|
if err != nil {
|
|
logger.ErrorContext(r.Context(), "get chat webhook", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
// putChatWebhook stores the webhook URL sealed, with its space label, and
|
|
// answers the settings as GET does.
|
|
func putChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := decodePatch[chatWebhookBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
if body.URL == nil || body.SpaceLabel == nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "url and space_label are required")
|
|
return
|
|
}
|
|
settings, err := svc.Put(r.Context(), *body.URL, *body.SpaceLabel)
|
|
var invalid *chat.ValidationError
|
|
if errors.As(err, &invalid) {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_webhook", invalid.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
logger.ErrorContext(r.Context(), "save chat webhook", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
// postChatWebhookTest sends chat.TestText to the stored webhook and answers
|
|
// 200 with the status Google Chat answered, whatever it was, and the
|
|
// recorded outcome. No webhook stored, or one replaced during the test, is
|
|
// 409; no answer from Google Chat is 502.
|
|
func postChatWebhookTest(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
result, err := svc.Test(r.Context())
|
|
switch {
|
|
case errors.Is(err, chat.ErrNotConfigured):
|
|
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured")
|
|
case errors.Is(err, chat.ErrWebhookChanged):
|
|
writeError(w, r, logger, http.StatusConflict, "webhook_changed", "the Google Chat webhook changed during the test; test it again")
|
|
case err != nil && r.Context().Err() != nil:
|
|
logger.WarnContext(r.Context(), "chat webhook test interrupted", "error", err)
|
|
case errors.Is(err, chat.ErrUnreachable):
|
|
logger.WarnContext(r.Context(), "chat webhook test failed", "error", err)
|
|
writeError(w, r, logger, http.StatusBadGateway, "chat_unreachable", "Google Chat did not answer the test message")
|
|
case err != nil:
|
|
logger.ErrorContext(r.Context(), "chat webhook test", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
default:
|
|
writeJSON(w, r, logger, http.StatusOK, result)
|
|
}
|
|
}
|
|
}
|