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>
227 lines
7.2 KiB
Go
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)
|
|
}
|