Extend the push pipeline to Android devices through the Silo push relay's /v1/fcm/send endpoint. push_devices gains platform-conditional FCM token columns (encrypted at rest with row AAD, hashed like APNs tokens), the generic POST /notifications/push/devices endpoint the Android client already calls registers FCM tokens, and fanout, operational dispatch, retries, and terminal UNREGISTERED device disabling all reuse the existing Apple machinery. Delivery is gated by a new notifications.android_push_delivery_enabled setting, advertised through the capability endpoint's android_push block, and testable via POST /admin/notifications/push/fcm/test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
500 lines
20 KiB
Go
500 lines
20 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SettingReader reads live server settings. Satisfied by
|
|
// catalog.ServerSettingsRepo and catalog.EncryptedSettingsRepo; declared
|
|
// locally to avoid a catalog dependency.
|
|
type SettingReader interface {
|
|
Get(ctx context.Context, key string) (string, error)
|
|
}
|
|
|
|
type settingBatchWriter interface {
|
|
SetMany(ctx context.Context, values map[string]string) error
|
|
}
|
|
|
|
// Server-setting keys for the notification system. All keys are live (no
|
|
// restart required): consumers read them through Settings, which caches reads
|
|
// briefly. The enabled flags default to on and act as kill switches — except
|
|
// webhooks and Discord, which are opt-in and stay off until an admin enables
|
|
// them. Flood safety comes from per-library seed markers, not from staged
|
|
// flag flips.
|
|
const (
|
|
SettingReleaseEventsEnabled = "notifications.release_events_enabled"
|
|
SettingFanoutEnabled = "notifications.fanout_enabled"
|
|
SettingUIEnabled = "notifications.ui_enabled"
|
|
SettingFanoutSettleSeconds = "notifications.fanout.settle_seconds"
|
|
SettingFanoutMaxSeriesBurst = "notifications.fanout.max_series_burst"
|
|
SettingFanoutMaxEventAge = "notifications.fanout.max_event_age_hours"
|
|
SettingRetentionReadDays = "notifications.retention.read_days"
|
|
SettingRetentionUnreadDays = "notifications.retention.unread_days"
|
|
SettingRetentionEventDays = "notifications.retention.event_days"
|
|
|
|
SettingWebhooksEnabled = "notifications.webhooks_enabled"
|
|
SettingWebhooksMaxPerProfile = "notifications.webhooks.max_per_profile"
|
|
SettingWebhooksAllowPrivate = "notifications.webhooks.allow_private_destinations"
|
|
SettingWebhooksRatePerMinute = "notifications.webhooks.deliveries_per_minute_per_profile"
|
|
|
|
SettingEmailEnabled = "notifications.email_enabled"
|
|
SettingEmailAllowPerEpisode = "notifications.email.allow_per_episode"
|
|
SettingEmailDigestHour = "notifications.email.digest_hour"
|
|
SettingEmailExternalURL = "notifications.email.external_url"
|
|
|
|
SettingDiscordEnabled = "notifications.discord_enabled"
|
|
SettingDiscordAllowPerEpisode = "notifications.discord.allow_per_episode"
|
|
SettingDiscordDigestHour = "notifications.discord.digest_hour"
|
|
SettingDiscordPosterMode = "notifications.discord.poster_mode"
|
|
|
|
SettingServerChannelsEnabled = "notifications.server_channels_enabled"
|
|
SettingServerChannelsBatchSeconds = "notifications.server_channels.batch_seconds"
|
|
SettingServerChannelMentionRequesters = "notifications.server_channels.mention_requesters"
|
|
|
|
SettingApplePushDeliveryEnabled = "notifications.apple_push_delivery_enabled"
|
|
SettingAndroidPushDeliveryEnabled = "notifications.android_push_delivery_enabled"
|
|
SettingPushRelayURL = "notifications.push_relay_url"
|
|
SettingPushRelayDeploymentID = "notifications.push_relay_deployment_id"
|
|
SettingPushRelayAPIKey = "notifications.push_relay_api_key"
|
|
SettingPushRelayExpiresAt = "notifications.push_relay_expires_at"
|
|
SettingPushRelayKeyPrefix = "notifications.push_relay_key_prefix"
|
|
SettingPushRelayReregister = "notifications.push_relay_reregistration_required"
|
|
|
|
// Discord application credentials live under the discord.* namespace
|
|
// (admin-configured, alongside email.smtp_*). The secret and bot token
|
|
// are registered in catalog.SensitiveSettingKeys and encrypted at rest.
|
|
SettingDiscordClientID = "discord.client_id"
|
|
SettingDiscordClientSecret = "discord.client_secret"
|
|
SettingDiscordBotToken = "discord.bot_token"
|
|
)
|
|
|
|
const (
|
|
defaultSettleSeconds = 30
|
|
defaultMaxSeriesBurst = 3
|
|
defaultMaxEventAgeHours = 72
|
|
defaultRetentionReadDays = 90
|
|
defaultRetentionUnread = 180
|
|
defaultRetentionEventDays = 30
|
|
// defaultDigestHour applies to every account-level digest channel
|
|
// (email, Discord).
|
|
defaultDigestHour = 8
|
|
|
|
// defaultServerChannelsBatchSeconds is the server-channel content batch
|
|
// window: how old a release event must be before the sweep reads it, so a
|
|
// season pack lands in one grouped post. The minimum must stay >= the
|
|
// availability detector's timeout (120s): an in-flight availability
|
|
// transaction can commit events whose created_at predates rows the sweep
|
|
// already passed, and the window is what keeps those visible.
|
|
defaultServerChannelsBatchSeconds = 300
|
|
minServerChannelsBatchSeconds = 120
|
|
// DefaultPushRelayURL is the public Silo relay origin used when no
|
|
// notifications.push_relay_url override is stored.
|
|
DefaultPushRelayURL = "https://push.siloserver.org"
|
|
|
|
settingsCacheTTL = 15 * time.Second
|
|
)
|
|
|
|
// Settings exposes typed accessors over live server settings with a short
|
|
// read-through cache so hot paths (ingest, fanout loop) do not hit the
|
|
// settings table on every call.
|
|
type Settings struct {
|
|
reader SettingReader
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
cache map[string]settingsCacheEntry
|
|
}
|
|
|
|
type settingsCacheEntry struct {
|
|
value string
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
// NewSettings creates a Settings facade. reader may be nil, in which case all
|
|
// accessors return their defaults.
|
|
func NewSettings(reader SettingReader) *Settings {
|
|
return &Settings{
|
|
reader: reader,
|
|
now: time.Now,
|
|
cache: make(map[string]settingsCacheEntry),
|
|
}
|
|
}
|
|
|
|
func (s *Settings) raw(ctx context.Context, key string) string {
|
|
if s == nil || s.reader == nil {
|
|
return ""
|
|
}
|
|
s.mu.Lock()
|
|
entry, ok := s.cache[key]
|
|
if ok && s.now().Sub(entry.fetchedAt) < settingsCacheTTL {
|
|
s.mu.Unlock()
|
|
return entry.value
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
value, err := s.reader.Get(ctx, key)
|
|
if err != nil {
|
|
// Fall back to the stale cached value (if any) rather than flapping
|
|
// to defaults on transient DB errors.
|
|
if ok {
|
|
return entry.value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.cache[key] = settingsCacheEntry{value: value, fetchedAt: s.now()}
|
|
s.mu.Unlock()
|
|
return value
|
|
}
|
|
|
|
// Invalidate drops cached values so the next read hits the store. Admin test
|
|
// paths use it: a test typically runs seconds after a settings save, inside
|
|
// the read-cache TTL, and must see the just-saved value.
|
|
func (s *Settings) Invalidate(keys ...string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
for _, key := range keys {
|
|
delete(s.cache, key)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// PushRelayCredential is the complete persisted identity of one relay
|
|
// capability generation. It must always be replaced as one atomic unit.
|
|
type PushRelayCredential struct {
|
|
RelayURL string
|
|
DeploymentID string
|
|
APIKey string
|
|
ExpiresAt time.Time
|
|
KeyPrefix string
|
|
ReregistrationRequired bool
|
|
}
|
|
|
|
// UpdatePushRelayCredential atomically persists all fields that identify a
|
|
// relay credential. Production requires a batch-capable settings repository;
|
|
// refusing a sequential fallback is what preserves the invariant.
|
|
func (s *Settings) UpdatePushRelayCredential(ctx context.Context, credential PushRelayCredential) error {
|
|
if s == nil || s.reader == nil {
|
|
return errors.New("push relay settings are unavailable")
|
|
}
|
|
writer, ok := s.reader.(settingBatchWriter)
|
|
if !ok {
|
|
return errors.New("push relay settings do not support atomic writes")
|
|
}
|
|
expiresAt := ""
|
|
if !credential.ExpiresAt.IsZero() {
|
|
expiresAt = credential.ExpiresAt.UTC().Format(time.RFC3339)
|
|
}
|
|
values := map[string]string{
|
|
SettingPushRelayURL: strings.TrimRight(strings.TrimSpace(credential.RelayURL), "/"),
|
|
SettingPushRelayDeploymentID: strings.TrimSpace(credential.DeploymentID),
|
|
SettingPushRelayAPIKey: strings.TrimSpace(credential.APIKey),
|
|
SettingPushRelayExpiresAt: expiresAt,
|
|
SettingPushRelayKeyPrefix: strings.TrimSpace(credential.KeyPrefix),
|
|
SettingPushRelayReregister: strconv.FormatBool(credential.ReregistrationRequired),
|
|
}
|
|
if err := writer.SetMany(ctx, values); err != nil {
|
|
return err
|
|
}
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
s.Invalidate(keys...)
|
|
return nil
|
|
}
|
|
|
|
func (s *Settings) boolSetting(ctx context.Context, key string, fallback bool) bool {
|
|
raw := strings.TrimSpace(strings.ToLower(s.raw(ctx, key)))
|
|
switch raw {
|
|
case "true", "1", "yes", "on":
|
|
return true
|
|
case "false", "0", "no", "off":
|
|
return false
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func (s *Settings) intSetting(ctx context.Context, key string, fallback, min, max int) int {
|
|
raw := strings.TrimSpace(s.raw(ctx, key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < min || value > max {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ReleaseEventsEnabled gates release-event creation during ingest.
|
|
func (s *Settings) ReleaseEventsEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingReleaseEventsEnabled, true)
|
|
}
|
|
|
|
// FanoutEnabled gates the fanout worker.
|
|
func (s *Settings) FanoutEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingFanoutEnabled, true)
|
|
}
|
|
|
|
// UIEnabled gates the inbox/preferences API surface advertised to clients.
|
|
func (s *Settings) UIEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingUIEnabled, true)
|
|
}
|
|
|
|
// SettleDelay is how old a release event must be before the fanout worker
|
|
// claims it, so one scan's burst for a series lands in the same claim batch.
|
|
func (s *Settings) SettleDelay(ctx context.Context) time.Duration {
|
|
return time.Duration(s.intSetting(ctx, SettingFanoutSettleSeconds, defaultSettleSeconds, 0, 3600)) * time.Second
|
|
}
|
|
|
|
// MaxSeriesBurst is the per-(library, series) cap on fanned-out events per
|
|
// claim batch; the remainder is suppressed with suppressed_reason.
|
|
func (s *Settings) MaxSeriesBurst(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingFanoutMaxSeriesBurst, defaultMaxSeriesBurst, 1, 1000)
|
|
}
|
|
|
|
// MaxEventAge bounds how old an unprocessed release event may be and still
|
|
// fan out. Events past the horizon (fanout disabled for a stretch, extended
|
|
// downtime) are suppressed as stale instead of being delivered long after the
|
|
// fact; retention deletes them.
|
|
func (s *Settings) MaxEventAge(ctx context.Context) time.Duration {
|
|
return time.Duration(s.intSetting(ctx, SettingFanoutMaxEventAge, defaultMaxEventAgeHours, 1, 24*365)) * time.Hour
|
|
}
|
|
|
|
// ReadRetentionDays bounds how long read inbox rows are kept.
|
|
func (s *Settings) ReadRetentionDays(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingRetentionReadDays, defaultRetentionReadDays, 1, 3650)
|
|
}
|
|
|
|
// UnreadRetentionDays bounds how long unread inbox rows are kept.
|
|
func (s *Settings) UnreadRetentionDays(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingRetentionUnreadDays, defaultRetentionUnread, 1, 3650)
|
|
}
|
|
|
|
// EventRetentionDays bounds how long processed release events are kept for
|
|
// debugging.
|
|
func (s *Settings) EventRetentionDays(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingRetentionEventDays, defaultRetentionEventDays, 1, 3650)
|
|
}
|
|
|
|
// WebhooksEnabled gates the outbound webhooks channel. Unlike the other
|
|
// channel flags this is opt-in: letting users point server-originated HTTP at
|
|
// arbitrary destinations is an admin decision, so creation, test sends, and
|
|
// delivery all stay off until an admin enables the setting.
|
|
func (s *Settings) WebhooksEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingWebhooksEnabled, false)
|
|
}
|
|
|
|
// WebhooksMaxPerProfile caps how many webhooks one profile may create.
|
|
func (s *Settings) WebhooksMaxPerProfile(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingWebhooksMaxPerProfile, 10, 1, 100)
|
|
}
|
|
|
|
// WebhooksAllowPrivateDestinations disables the private-destination guard.
|
|
// Intended only for development environments.
|
|
func (s *Settings) WebhooksAllowPrivateDestinations(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingWebhooksAllowPrivate, false)
|
|
}
|
|
|
|
// WebhooksDeliveriesPerMinute is the per-profile webhook delivery rate limit.
|
|
// Over-limit notifications stay in the inbox; webhooks just don't fire.
|
|
func (s *Settings) WebhooksDeliveriesPerMinute(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingWebhooksRatePerMinute, 60, 1, 10000)
|
|
}
|
|
|
|
// EmailEnabled gates the email notification channel (kill switch). Actual
|
|
// availability additionally requires a configured SMTP sender (mail.Sender).
|
|
func (s *Settings) EmailEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingEmailEnabled, true)
|
|
}
|
|
|
|
// EmailAllowPerEpisode controls whether users may choose per-episode email
|
|
// alerts. When off, accounts set to per-episode are coerced to the daily
|
|
// digest instead of going silent.
|
|
func (s *Settings) EmailAllowPerEpisode(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingEmailAllowPerEpisode, true)
|
|
}
|
|
|
|
// EmailDigestHour is the hour of day (0-23, server-local time) at which daily
|
|
// digest emails go out.
|
|
func (s *Settings) EmailDigestHour(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingEmailDigestHour, defaultDigestHour, 0, 23)
|
|
}
|
|
|
|
// EmailExternalURL is the externally reachable base URL of this server, used
|
|
// for deep links inside notification emails. Empty renders emails without
|
|
// links (webhooks deliberately never leak the origin; email is opt-in here).
|
|
func (s *Settings) EmailExternalURL(ctx context.Context) string {
|
|
return strings.TrimRight(strings.TrimSpace(s.raw(ctx, SettingEmailExternalURL)), "/")
|
|
}
|
|
|
|
// DiscordEnabled is the master switch for the Discord bot integration. Like
|
|
// webhooks it is opt-in: while off, the channel never delivers, linking is
|
|
// refused, and clients are told the channel is unavailable (which hides the
|
|
// Discord section in user settings). Actual availability additionally
|
|
// requires the configured bot credentials.
|
|
func (s *Settings) DiscordEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingDiscordEnabled, false)
|
|
}
|
|
|
|
// Discord embed poster modes (SettingDiscordPosterMode values).
|
|
const (
|
|
// DiscordPostersOff renders all Discord embeds without artwork.
|
|
DiscordPostersOff = "off"
|
|
// DiscordPostersProvider (default) allows artwork served by public
|
|
// provider CDNs only (image.tmdb.org, artworks.thetvdb.com).
|
|
DiscordPostersProvider = "provider"
|
|
// DiscordPostersServer additionally falls back to presigned URLs from
|
|
// this server's own image storage for locally cached artwork. The
|
|
// storage origin becomes visible to the destination and must be
|
|
// reachable from the internet for Discord to render the image.
|
|
DiscordPostersServer = "server"
|
|
)
|
|
|
|
// DiscordPosterMode controls artwork in outbound Discord embeds; unknown
|
|
// values fall back to the provider-CDN-only default.
|
|
func (s *Settings) DiscordPosterMode(ctx context.Context) string {
|
|
switch strings.TrimSpace(s.raw(ctx, SettingDiscordPosterMode)) {
|
|
case DiscordPostersOff:
|
|
return DiscordPostersOff
|
|
case DiscordPostersServer:
|
|
return DiscordPostersServer
|
|
default:
|
|
return DiscordPostersProvider
|
|
}
|
|
}
|
|
|
|
// DiscordAllowPerEpisode controls whether users may choose per-episode
|
|
// Discord DMs. When off, accounts set to per-episode are coerced to the daily
|
|
// digest instead of going silent.
|
|
func (s *Settings) DiscordAllowPerEpisode(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingDiscordAllowPerEpisode, true)
|
|
}
|
|
|
|
// DiscordDigestHour is the hour of day (0-23, server-local time) at which
|
|
// daily digest DMs go out.
|
|
func (s *Settings) DiscordDigestHour(ctx context.Context) int {
|
|
return s.intSetting(ctx, SettingDiscordDigestHour, defaultDigestHour, 0, 23)
|
|
}
|
|
|
|
// ServerChannelsEnabled gates the admin server-channel feature (kill switch).
|
|
// Defaults to on: unlike profile webhooks, every destination is created by an
|
|
// admin, so creating a channel is itself the opt-in act.
|
|
func (s *Settings) ServerChannelsEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingServerChannelsEnabled, true)
|
|
}
|
|
|
|
// ServerChannelMentionRequesters controls whether server-channel request
|
|
// posts to Discord destinations @mention the requesting user (when that
|
|
// account has linked Discord). Opt-in: a mention pings the user, which an
|
|
// admin must choose deliberately for a shared channel.
|
|
func (s *Settings) ServerChannelMentionRequesters(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingServerChannelMentionRequesters, false)
|
|
}
|
|
|
|
// ServerChannelsBatchWindow is how old a release event must be before the
|
|
// server-channel sweep reads it (content posts batch within this window).
|
|
func (s *Settings) ServerChannelsBatchWindow(ctx context.Context) time.Duration {
|
|
return time.Duration(s.intSetting(ctx, SettingServerChannelsBatchSeconds,
|
|
defaultServerChannelsBatchSeconds, minServerChannelsBatchSeconds, 3600)) * time.Second
|
|
}
|
|
|
|
// ApplePushDeliveryEnabled gates relay sends and the capability endpoint's
|
|
// apple_push availability, mirroring how web push advertises itself. The
|
|
// device registration endpoint stays available independently so clients that
|
|
// already hold tokens keep them fresh across admin toggles.
|
|
func (s *Settings) ApplePushDeliveryEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingApplePushDeliveryEnabled, false)
|
|
}
|
|
|
|
// AndroidPushDeliveryEnabled is the Android counterpart of
|
|
// ApplePushDeliveryEnabled: it gates relay FCM sends and the capability
|
|
// endpoint's android_push availability.
|
|
func (s *Settings) AndroidPushDeliveryEnabled(ctx context.Context) bool {
|
|
return s.boolSetting(ctx, SettingAndroidPushDeliveryEnabled, false)
|
|
}
|
|
|
|
// PushDeliveryEnabled reports whether any push platform may deliver; the
|
|
// shared dispatcher uses it as its wake-up gate.
|
|
func (s *Settings) PushDeliveryEnabled(ctx context.Context) bool {
|
|
return s.ApplePushDeliveryEnabled(ctx) || s.AndroidPushDeliveryEnabled(ctx)
|
|
}
|
|
|
|
// EnabledPushPlatforms lists the platforms whose admin delivery toggle is on,
|
|
// in the shape ListEnabledPushByProfiles expects.
|
|
func (s *Settings) EnabledPushPlatforms(ctx context.Context) []string {
|
|
platforms := make([]string, 0, 2)
|
|
if s.ApplePushDeliveryEnabled(ctx) {
|
|
platforms = append(platforms, PushPlatformApple)
|
|
}
|
|
if s.AndroidPushDeliveryEnabled(ctx) {
|
|
platforms = append(platforms, PushPlatformAndroid)
|
|
}
|
|
return platforms
|
|
}
|
|
|
|
// PushRelayURL is the public Silo relay origin.
|
|
func (s *Settings) PushRelayURL(ctx context.Context) string {
|
|
value := strings.TrimRight(strings.TrimSpace(s.raw(ctx, SettingPushRelayURL)), "/")
|
|
if value == "" {
|
|
return DefaultPushRelayURL
|
|
}
|
|
return value
|
|
}
|
|
|
|
// PushRelayAPIKey is the bearer credential for the Silo relay.
|
|
func (s *Settings) PushRelayAPIKey(ctx context.Context) string {
|
|
return strings.TrimSpace(s.raw(ctx, SettingPushRelayAPIKey))
|
|
}
|
|
|
|
// PushRelayDeploymentID is the relay account identifier returned during
|
|
// self-registration. The dispatcher does not need it; registration uses it for
|
|
// subsequent key rotations.
|
|
func (s *Settings) PushRelayDeploymentID(ctx context.Context) string {
|
|
return strings.TrimSpace(s.raw(ctx, SettingPushRelayDeploymentID))
|
|
}
|
|
|
|
func (s *Settings) PushRelayExpiresAt(ctx context.Context) time.Time {
|
|
value := strings.TrimSpace(s.raw(ctx, SettingPushRelayExpiresAt))
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func (s *Settings) PushRelayKeyPrefix(ctx context.Context) string {
|
|
return strings.TrimSpace(s.raw(ctx, SettingPushRelayKeyPrefix))
|
|
}
|
|
|
|
func (s *Settings) PushRelayReregistrationRequired(ctx context.Context) bool {
|
|
value, _ := strconv.ParseBool(strings.TrimSpace(s.raw(ctx, SettingPushRelayReregister)))
|
|
return value
|
|
}
|
|
|
|
// DiscordClientID is the Discord application's OAuth2 client ID.
|
|
func (s *Settings) DiscordClientID(ctx context.Context) string {
|
|
return strings.TrimSpace(s.raw(ctx, SettingDiscordClientID))
|
|
}
|
|
|
|
// DiscordClientSecret is the Discord application's OAuth2 client secret.
|
|
func (s *Settings) DiscordClientSecret(ctx context.Context) string {
|
|
return strings.TrimSpace(s.raw(ctx, SettingDiscordClientSecret))
|
|
}
|
|
|
|
// DiscordBotToken is the Discord bot token used to open DM channels and send
|
|
// messages.
|
|
func (s *Settings) DiscordBotToken(ctx context.Context) string {
|
|
return strings.TrimSpace(s.raw(ctx, SettingDiscordBotToken))
|
|
}
|