Adds Discord direct messages as a notification channel. Users link their Discord account via OAuth2 (identify scope only, one-time server-side state rows); a bot delivers their inbox notifications as DMs. - Extract the email channel's watermark sweep into a generic account-channel engine; email and Discord are now thin adapters, so the SKIP LOCKED claim / watermark-after-send durability logic exists once. - New internal/discord REST client (token exchange, identity, open DM, send message) — no Gateway connection, no new dependencies. - Opt-in master switch (notifications.discord_enabled, default off) gates delivery, linking, capability, and the admin settings reveal. - Admin UI: credentials (secret + bot token encrypted at rest), dev portal setup checklist, bot invite link buttons, and a test button that bypasses the settings read cache and is disabled while credential edits are unsaved. - DM failures from missing shared guild (Discord 50007) surface as link health in user settings and self-heal via capped backoff. - New combined mode (per_episode_and_digest) for email and Discord: instant sends all day plus a daily digest recapping the whole window since the previous digest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
4.2 KiB
Go
137 lines
4.2 KiB
Go
package discord
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
func testClient(t *testing.T, handler http.Handler) *Client {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
return &Client{
|
|
httpClient: server.Client(),
|
|
apiBase: server.URL,
|
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
|
}
|
|
}
|
|
|
|
func TestExchangeCodeAndGetUser(t *testing.T) {
|
|
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/oauth2/token":
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok || user != "client-id" || pass != "client-secret" {
|
|
t.Errorf("missing or wrong basic auth: %s/%s", user, pass)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != "the-code" {
|
|
t.Errorf("unexpected form: %v", r.PostForm)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "user-token"})
|
|
case "/users/@me":
|
|
if got := r.Header.Get("Authorization"); got != "Bearer user-token" {
|
|
t.Errorf("unexpected authorization %q", got)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(User{ID: "42", Username: "quick"})
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
|
|
token, err := client.ExchangeCode(context.Background(), "client-id", "client-secret", "the-code", "https://silo.example/cb")
|
|
if err != nil {
|
|
t.Fatalf("exchange: %v", err)
|
|
}
|
|
user, err := client.GetUser(context.Background(), token)
|
|
if err != nil {
|
|
t.Fatalf("get user: %v", err)
|
|
}
|
|
if user.ID != "42" || user.Username != "quick" {
|
|
t.Fatalf("unexpected user %+v", user)
|
|
}
|
|
}
|
|
|
|
func TestOpenDMChannelAndSendDM(t *testing.T) {
|
|
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/users/@me/channels":
|
|
if got := r.Header.Get("Authorization"); got != "Bot bot-token" {
|
|
t.Errorf("unexpected authorization %q", got)
|
|
}
|
|
var body map[string]string
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
if body["recipient_id"] != "42" {
|
|
t.Errorf("unexpected recipient %q", body["recipient_id"])
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": "dm-123"})
|
|
case "/channels/dm-123/messages":
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
|
|
channelID, err := client.OpenDMChannel(context.Background(), "bot-token", "42")
|
|
if err != nil {
|
|
t.Fatalf("open dm: %v", err)
|
|
}
|
|
if channelID != "dm-123" {
|
|
t.Fatalf("unexpected channel id %q", channelID)
|
|
}
|
|
if err := client.SendDM(context.Background(), "bot-token", channelID, []byte(`{"content":"hi"}`)); err != nil {
|
|
t.Fatalf("send dm: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestErrorMapping(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
want error
|
|
}{
|
|
{"dm blocked", http.StatusForbidden, `{"code":50007,"message":"Cannot send messages to this user"}`, ErrDMBlocked},
|
|
{"bad token", http.StatusUnauthorized, `{"message":"401: Unauthorized"}`, ErrUnauthorized},
|
|
{"rate limited", http.StatusTooManyRequests, `{"message":"You are being rate limited."}`, ErrRateLimited},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(tc.status)
|
|
_, _ = w.Write([]byte(tc.body))
|
|
}))
|
|
err := client.SendDM(context.Background(), "bot-token", "dm-123", []byte(`{}`))
|
|
if !errors.Is(err, tc.want) {
|
|
t.Fatalf("got %v, want %v", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLimiterHonorsContextCancel(t *testing.T) {
|
|
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
// Exhausted limiter with a long refill forces Wait to block on the context.
|
|
client.limiter = rate.NewLimiter(rate.Every(time.Hour), 1)
|
|
client.limiter.Allow()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
|
defer cancel()
|
|
if err := client.SendDM(ctx, "bot-token", "dm-123", []byte(`{}`)); err == nil {
|
|
t.Fatal("expected context error from limiter wait")
|
|
}
|
|
}
|