Files
silo-server/internal/discord/client.go
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
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>
2026-06-11 19:51:03 -04:00

227 lines
7.2 KiB
Go

// Package discord is a minimal Discord REST client covering exactly what the
// notification channel needs: the OAuth2 code exchange used for account
// linking, identity lookups, and bot DM delivery. No Gateway connection is
// held; everything is short-lived REST against a fixed trusted host.
package discord
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/time/rate"
)
const (
defaultAPIBase = "https://discord.com/api/v10"
// AuthorizeURL is the user-facing OAuth2 consent page.
AuthorizeURL = "https://discord.com/oauth2/authorize"
requestTimeout = 10 * time.Second
// Discord asks bot user agents to identify themselves in this format.
userAgent = "DiscordBot (https://github.com/Silo-Server/silo-server, 1.0)"
// errorBodyLimit bounds how much of an error response is read for
// diagnostics.
errorBodyLimit = 4 << 10
)
// Sentinel errors for the failure modes callers branch on.
var (
// ErrDMBlocked is Discord error 50007: the bot cannot DM this user. The
// user does not share a guild with the bot or has server DMs disabled.
ErrDMBlocked = errors.New("discord: cannot send messages to this user")
// ErrUnauthorized means the bot token or OAuth credentials were rejected.
ErrUnauthorized = errors.New("discord: unauthorized")
// ErrRateLimited means Discord returned 429; retry later.
ErrRateLimited = errors.New("discord: rate limited")
)
const dmBlockedCode = 50007
// User is the subset of a Discord user object the integration stores.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
}
// Client makes Discord REST calls. Tokens are passed per call so callers can
// read live settings; the client itself holds no credentials.
type Client struct {
httpClient *http.Client
apiBase string
// limiter paces outbound calls well under Discord's global rate limits
// (~50 req/s global, ~5 DMs/s); notification volume is far below this,
// but digest-hour bursts across many accounts need smoothing.
limiter *rate.Limiter
}
// NewClient creates a Client.
func NewClient() *Client {
return &Client{
httpClient: &http.Client{Timeout: requestTimeout},
apiBase: defaultAPIBase,
limiter: rate.NewLimiter(rate.Every(250*time.Millisecond), 4),
}
}
// ExchangeCode performs the OAuth2 authorization-code exchange and returns
// the user access token. Only the identify scope is requested at authorize
// time, so the token can do nothing beyond reading the user's own identity.
func (c *Client) ExchangeCode(ctx context.Context, clientID, clientSecret, code, redirectURI string) (string, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.apiBase+"/oauth2/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(clientID, clientSecret)
var token struct {
AccessToken string `json:"access_token"`
}
if err := c.do(req, &token); err != nil {
return "", fmt.Errorf("exchange oauth code: %w", err)
}
if token.AccessToken == "" {
return "", errors.New("discord: token response missing access_token")
}
return token.AccessToken, nil
}
// GetUser returns the identity behind a user access token (GET /users/@me).
func (c *Client) GetUser(ctx context.Context, accessToken string) (User, error) {
return c.getMe(ctx, "Bearer "+accessToken)
}
// GetBotUser returns the bot's own identity, verifying the bot token. Used by
// the admin "test" endpoint.
func (c *Client) GetBotUser(ctx context.Context, botToken string) (User, error) {
return c.getMe(ctx, "Bot "+botToken)
}
func (c *Client) getMe(ctx context.Context, authorization string) (User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiBase+"/users/@me", nil)
if err != nil {
return User{}, err
}
req.Header.Set("Authorization", authorization)
var user User
if err := c.do(req, &user); err != nil {
return User{}, fmt.Errorf("get current user: %w", err)
}
if user.ID == "" {
return User{}, errors.New("discord: user response missing id")
}
return user, nil
}
// OpenDMChannel opens (or returns the existing) DM channel with the user.
// Idempotent: Discord returns the same channel for repeated calls.
func (c *Client) OpenDMChannel(ctx context.Context, botToken, recipientDiscordUserID string) (string, error) {
body, err := json.Marshal(map[string]string{"recipient_id": recipientDiscordUserID})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.apiBase+"/users/@me/channels", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bot "+botToken)
var channel struct {
ID string `json:"id"`
}
if err := c.do(req, &channel); err != nil {
return "", fmt.Errorf("open dm channel: %w", err)
}
if channel.ID == "" {
return "", errors.New("discord: channel response missing id")
}
return channel.ID, nil
}
// SendDM posts a message payload (Discord message JSON, e.g. an embeds body)
// to a DM channel.
func (c *Client) SendDM(ctx context.Context, botToken, channelID string, payload []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.apiBase+"/channels/"+url.PathEscape(channelID)+"/messages", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bot "+botToken)
if err := c.do(req, nil); err != nil {
return fmt.Errorf("send dm: %w", err)
}
return nil
}
// do executes the request under the rate limiter and decodes a 2xx JSON
// response into out (when non-nil). Non-2xx responses map to sentinel errors
// with the Discord error message attached.
func (c *Client) do(req *http.Request, out any) error {
if err := c.limiter.Wait(req.Context()); err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return apiError(resp)
}
if out == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, errorBodyLimit))
return nil
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out); err != nil {
return fmt.Errorf("decode discord response: %w", err)
}
return nil
}
// apiError maps a non-2xx response to a sentinel error, preserving Discord's
// message for logs and UI surfacing.
func apiError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, errorBodyLimit))
var payload struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &payload)
switch {
case payload.Code == dmBlockedCode:
return ErrDMBlocked
case resp.StatusCode == http.StatusUnauthorized:
return fmt.Errorf("%w: %s", ErrUnauthorized, payload.Message)
case resp.StatusCode == http.StatusTooManyRequests:
return ErrRateLimited
}
message := payload.Message
if message == "" {
message = strings.TrimSpace(string(body))
}
return fmt.Errorf("discord: HTTP %d: %s", resp.StatusCode, message)
}