discord

package
v0.0.0-...-fe5d286 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: AGPL-3.0 Imports: 32 Imported by: 0

Documentation

Overview

Package discord provides a client for sending messages via Discord webhooks.

Index

Constants

View Source
const (
	// APIBaseURL is the pinned Discord REST API base. Pinned to a major
	// version rather than floating so a Discord rollout cannot silently
	// change payload semantics under a running deployment.
	APIBaseURL = "https://discord.com/api/v10"

	// OAuthAuthorizeURL is where a bot install starts.
	OAuthAuthorizeURL = "https://discord.com/oauth2/authorize"
	// OAuthTokenURL is the code→token exchange endpoint.
	OAuthTokenURL = "https://discord.com/api/oauth2/token"
)

Discord API constants shared by the bot client, the interactions endpoint and the Gateway supervisor.

View Source
const (
	ChannelTypeGuildText          = 0
	ChannelTypeGuildVoice         = 2
	ChannelTypeGuildCategory      = 4
	ChannelTypeGuildAnnouncement  = 5
	ChannelTypeAnnouncementThread = 10
	ChannelTypePublicThread       = 11
	ChannelTypePrivateThread      = 12
)

Discord channel types. Only the text-ish ones can receive a notification, which is what the destinations picker filters on.

View Source
const (
	ComponentTypeActionRow = 1
	ComponentTypeButton    = 2
)

Message component types (Discord "message components").

View Source
const (
	ButtonStylePrimary   = 1
	ButtonStyleSecondary = 2
	ButtonStyleSuccess   = 3
	ButtonStyleDanger    = 4
	ButtonStyleLink      = 5
)

Button styles.

View Source
const (
	InteractionTypePing               = 1
	InteractionTypeApplicationCommand = 2
	InteractionTypeMessageComponent   = 3
	InteractionTypeAutocomplete       = 4
	InteractionTypeModalSubmit        = 5
)

Interaction types delivered to the interactions endpoint.

View Source
const (
	InteractionCallbackPong                 = 1
	InteractionCallbackChannelMessage       = 4
	InteractionCallbackDeferredChannelMsg   = 5
	InteractionCallbackDeferredUpdateMsg    = 6
	InteractionCallbackUpdateMessage        = 7
	InteractionCallbackAutocompleteResponse = 8
)

Interaction callback (response) types.

View Source
const (
	// ActionAcknowledge acknowledges an incident.
	ActionAcknowledge = "ack"
	// ActionUnavailable declares the presser unavailable.
	ActionUnavailable = "unavailable"
	// ActionEscalate escalates the incident immediately.
	ActionEscalate = "escalate"
)

Message-component custom ids. Discord gives a component a single opaque string, so the action and its subject are packed into one value with a `:` separator — the Discord counterpart of Slack's action_id + value pair.

View Source
const (

	// ThreadIncidentUIDKey / ThreadOrgUIDKey are the value keys of a reverse
	// thread entry. Exported so the writer and the reader cannot drift.
	ThreadIncidentUIDKey = "incident_uid"
	ThreadOrgUIDKey      = "organization_uid"
)

State-entry key schema shared between the notification sender (which writes the reverse thread mapping when it opens an incident's thread) and the Gateway (which resolves an inbound reply back to its incident).

View Source
const (
	ColorRed    = 16711680 // #FF0000 - Active/reopened incidents
	ColorGreen  = 65280    // #00FF00 - Resolved incidents
	ColorOrange = 16744448 // #FFA500 - Escalations
	ColorBlue   = 3447003  // #3498DB - Info
)

Discord embed colors.

View Source
const (
	// SignatureHeader carries the hex-encoded Ed25519 signature.
	SignatureHeader = "X-Signature-Ed25519"
	// TimestampHeader carries the Unix timestamp the signature covers.
	TimestampHeader = "X-Signature-Timestamp"

	// MaxTimestampAge bounds replay of a captured, correctly-signed request.
	// Discord itself does not require this; we do, for the same reason Slack
	// does — a signature with no freshness bound is a bearer token that never
	// expires.
	MaxTimestampAge = 5 * time.Minute
)
View Source
const DefaultGatewayURL = "wss://gateway.discord.gg/?v=10&encoding=json"

DefaultGatewayURL is Discord's public Gateway entry point.

View Source
const (
	// DefaultTimeout is the default HTTP client timeout.
	DefaultTimeout = 30 * time.Second
)
View Source
const MessageFlagEphemeral = 1 << 6

MessageFlagEphemeral marks an interaction reply as visible only to the invoking user — the Discord equivalent of Slack's ephemeral response type.

Variables

View Source
var (
	// ErrDiscordWebhook is returned when a Discord webhook call fails.
	ErrDiscordWebhook = errors.New("discord webhook error")
	// ErrUnexpectedStatus is returned when an unexpected HTTP status is received.
	ErrUnexpectedStatus = errors.New("unexpected HTTP status")
)
View Source
var (
	// ErrConnectionNotFound is returned when a connection is not found.
	ErrConnectionNotFound = errors.New("connection not found")
	// ErrOrganizationNotFound is returned when an organization is not found.
	ErrOrganizationNotFound = errors.New("organization not found")
	// ErrNotDiscordChannel is returned when the channel is not of type discord.
	ErrNotDiscordChannel = errors.New("channel is not of type discord")
	// ErrDiscordNotConnected is returned when a Discord integration has no
	// guild (a legacy webhook-only row, or a manually-created stub). Such a
	// channel must be connected through the bot install before its guild
	// channels can be listed.
	ErrDiscordNotConnected = errors.New("discord channel has no guild — install the bot")
	// ErrInvalidState is returned when the OAuth state is invalid.
	ErrInvalidState = errors.New("invalid OAuth state")
	// ErrOAuthFailed is returned when the token exchange fails.
	ErrOAuthFailed = errors.New("OAuth exchange failed")
	// ErrGuildMissing is returned when the install callback carries no guild —
	// the user completed an authorization that did not add the bot anywhere.
	ErrGuildMissing = errors.New("discord install returned no guild")
	// ErrBotNotConfigured is returned when the instance has no Discord bot
	// token, so nothing can be installed.
	ErrBotNotConfigured = errors.New("discord bot is not configured on this instance")
)

Service-level sentinel errors.

View Source
var ErrBotTokenMissing = errors.New("discord bot token not configured")

ErrBotTokenMissing is returned when a bot call is attempted with no token.

View Source
var ErrNotFound = errors.New("discord resource not found")

ErrNotFound is returned when Discord answers 404 — a deleted channel, an unknown message, a thread that no longer exists. Callers distinguish it so "the destination is gone" can degrade differently from "Discord is broken".

Functions

func BuildCustomID

func BuildCustomID(action, subject string) string

BuildCustomID packs an action and its subject into a component custom id.

func CommentDedupeStateKey

func CommentDedupeStateKey(guildID, channelID, messageID string) string

CommentDedupeStateKey builds the per-message idempotency marker key that stops a Gateway redelivery (a RESUME replaying events, a reconnect) from recording the same comment twice.

func IncidentLabel

func IncidentLabel(incident *models.Incident) string

IncidentLabel renders "#42 (api is down)", degrading gracefully for an incident created before the per-org numbers existed.

func IsPostableChannelType

func IsPostableChannelType(t int) bool

IsPostableChannelType reports whether a guild channel of this type can receive a top-level incident message. Voice and category rows are returned by Discord's channel list and would otherwise show up in the picker as destinations that silently fail at send time.

func IsThreadChannelType

func IsThreadChannelType(t int) bool

IsThreadChannelType reports whether a channel id refers to a thread.

func LookupThreadIncident

func LookupThreadIncident(
	ctx context.Context, dbService db.Service, guildID, threadID string,
) (string, string, bool)

LookupThreadIncident resolves a Discord thread back to the incident whose notification opened it, using the reverse mapping the sender writes.

Returns (incidentUID, orgUID, found). `found` is false — with no error — for a thread we do not track, which is the common case: most threads in a guild have nothing to do with SolidPing.

func MentionsBot

func MentionsBot(text, botUserID string) bool

MentionsBot reports whether a message text starts by mentioning this bot. Discord delivers the mention as `<@bot_id>` (or `<@!bot_id>` for a nickname mention), so both shapes must match or half the invocations look like plain chatter.

func ParseChannelReference

func ParseChannelReference(ref string) string

ParseChannelReference extracts a channel id from a `<#123>` mention or a bare id.

A plain `#name` yields "" and is refused: resolving a name would mean picking one of possibly several channels by guess, and the failure mode of guessing wrong is that alerts go somewhere nobody is watching. Note that this function is NOT the authorization gate — SetDefaultChannel still proves the id belongs to the invoking guild before storing it.

func ParseCustomID

func ParseCustomID(customID string) (string, string)

ParseCustomID splits a component custom id back into its action and subject. A malformed id yields ("", "") so a caller can reject it without a panic.

func ReverseThreadStateKey

func ReverseThreadStateKey(guildID, threadID string) string

ReverseThreadStateKey maps a Discord thread (guild, thread) back to its incident. Stored as a global (org-nil) entry: the guild's home org need not own the incident, so the value carries the incident's org UID rather than scoping the key to it.

func VerifySignature

func VerifySignature(key ed25519.PublicKey, timestamp string, body []byte, hexSignature string) bool

VerifySignature checks Discord's Ed25519 signature over `timestamp + body`. Exported so the Gateway/testing paths can reuse exactly the same check.

Types

type AllowedMentions

type AllowedMentions struct {
	Parse []string `json:"parse"`
	Users []string `json:"users,omitempty"`
}

AllowedMentions bounds who a message may ping. SolidPing only ever pings the specific users it resolved from the escalation policy, so `parse` stays empty (which disables @everyone/@here/role pings) and `users` carries the explicit allow-list.

type BotClient

type BotClient struct {
	// contains filtered or unexported fields
}

BotClient is a Discord REST client authenticated as the application's bot.

It is deliberately separate from Client (the legacy webhook poster): a webhook connection has no token and no bot capabilities, and keeping the two apart is what makes it structurally impossible for the bot rework to break the legacy path.

func NewBotClient

func NewBotClient(token string) *BotClient

NewBotClient creates a bot-authenticated Discord REST client.

func (*BotClient) CreateMessage

func (c *BotClient) CreateMessage(
	ctx context.Context, channelID string, msg *Message,
) (*MessageResult, error)

CreateMessage posts a message into a channel (or a thread — a thread id is a channel id in Discord's model).

func (*BotClient) EditMessage

func (c *BotClient) EditMessage(
	ctx context.Context, channelID, messageID string, msg *Message,
) error

EditMessage rewrites an existing bot message in place. This is what turns the original "New incident" embed into a resolved one instead of leaving a stale red card at the top of the channel.

func (*BotClient) GetChannel

func (c *BotClient) GetChannel(ctx context.Context, channelID string) (*ChannelInfo, error)

GetChannel fetches one channel (or thread) by id.

func (*BotClient) GetCurrentUser

func (c *BotClient) GetCurrentUser(ctx context.Context) (*User, error)

GetCurrentUser returns the bot's own user object (used to learn the bot user id so the Gateway can ignore its own messages).

func (*BotClient) GetGuild

func (c *BotClient) GetGuild(ctx context.Context, guildID string) (*Guild, error)

GetGuild fetches a guild by id.

func (*BotClient) ListGuildChannels

func (c *BotClient) ListGuildChannels(ctx context.Context, guildID string) ([]ChannelInfo, error)

ListGuildChannels returns every channel of a guild.

func (*BotClient) StartThreadFromMessage

func (c *BotClient) StartThreadFromMessage(
	ctx context.Context, channelID, messageID, name string,
) (*ChannelInfo, error)

StartThreadFromMessage creates a thread hanging off an existing message. Discord truncates thread names at 100 characters, so we do it first rather than letting the API 400 on a long check name.

func (*BotClient) UnarchiveThread

func (c *BotClient) UnarchiveThread(ctx context.Context, threadID string) error

UnarchiveThread clears a thread's archived flag.

Discord auto-archives a thread after its inactivity window (1 day to 1 week) and a POST into an archived thread fails, so every late follow-up — the resolve message on a long incident above all — must un-archive first. This is deliberately unconditional and best-effort at the call site: asking Discord whether the thread is archived costs the same round trip as simply clearing the flag.

func (*BotClient) WithBaseURL

func (c *BotClient) WithBaseURL(base string) *BotClient

WithBaseURL points the client at another base URL. Used by tests to drive the real request-building code against an httptest stand-in.

type ChannelInfo

type ChannelInfo struct {
	ID       string          `json:"id"`
	Name     string          `json:"name"`
	Type     int             `json:"type"`
	ParentID string          `json:"parent_id,omitempty"`
	Position int             `json:"position,omitempty"`
	Metadata *ThreadMetadata `json:"thread_metadata,omitempty"`
}

ChannelInfo is a guild channel or thread as returned by the REST API.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a Discord webhook client.

func NewClient

func NewClient(webhookURL string) *Client

NewClient creates a new Discord webhook client.

func (*Client) SendWebhookMessage

func (cl *Client) SendWebhookMessage(ctx context.Context, msg *WebhookMessage) error

SendWebhookMessage sends a message via a Discord webhook.

type Command

type Command struct {
	GuildID   string
	ChannelID string
	// ThreadID is set when the command was invoked inside a thread. It is what
	// lets `comment` figure out which incident is being discussed without the
	// user naming it.
	ThreadID string
	UserID   string
	UserName string

	Command    string
	Subcommand string
	Args       []string
	Flags      map[string]string
}

Command is a transport-agnostic SolidPing command invocation.

Both entry points build one of these — the HTTP interactions endpoint from a Discord application command, and the Gateway from an `@SolidPing …` mention — so the command set is implemented exactly once and cannot drift between transports.

func CommandFromInteraction

func CommandFromInteraction(interaction *Interaction) *Command

CommandFromInteraction flattens a Discord application command into a Command.

Discord models `/solidping checks add <url>` as a top-level command with a SUB_COMMAND_GROUP option containing a SUB_COMMAND option containing the arguments; the SolidPing command set is `<command> <subcommand> <args>`, so the nesting is unwrapped here once rather than at each command.

func ParseMentionText

func ParseMentionText(text string) *Command

ParseMentionText turns `@SolidPing checks add https://acme.com -slug acme` into a Command. Mirrors the Slack parser so a user who knows one knows the other.

type CommandResponse

type CommandResponse struct {
	Text string
	// Ephemeral marks a reply only the invoking user should see. Honored by
	// the interactions transport; the Gateway has no ephemeral messages, so it
	// posts the reply normally.
	Ephemeral bool
}

CommandResponse is the reply a command produces.

func DispatchCommand

func DispatchCommand(ctx context.Context, svc *Service, cmd *Command) (*CommandResponse, error)

DispatchCommand is the single dispatch entry for SolidPing commands.

type Component

type Component struct {
	Type       int         `json:"type"`
	Style      int         `json:"style,omitempty"`
	Label      string      `json:"label,omitempty"`
	CustomID   string      `json:"custom_id,omitempty"`
	URL        string      `json:"url,omitempty"`
	Disabled   bool        `json:"disabled,omitempty"`
	Components []Component `json:"components,omitempty"`
}

Component is a message component. Only action rows and buttons are used; the shape is flat rather than a union so the JSON stays simple.

func IncidentActionRow

func IncidentActionRow(incidentUID string) Component

IncidentActionRow builds the action row attached to an active incident message. Kept here rather than in the notifications package so the sender (which writes the buttons) and the interactions endpoint (which reads their custom ids) share one definition and cannot drift.

type CreateCheckResult

type CreateCheckResult struct {
	Slug string
	Name string
}

CreateCheckResult contains the result of creating a check via Discord.

type DiscordChannel

type DiscordChannel struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Type int    `json:"type"`
}

DiscordChannel is a destination entry returned by GetDestinations.

type DiscordDestinationsResponse

type DiscordDestinationsResponse struct {
	Channels  []DiscordChannel `json:"channels"`
	GuildID   string           `json:"guildId"`
	GuildName string           `json:"guildName"`
	Connected bool             `json:"connected"`
}

DiscordDestinationsResponse is returned by GetDestinations.

type Embed

type Embed struct {
	Title       string  `json:"title,omitempty"`
	URL         string  `json:"url,omitempty"`
	Description string  `json:"description,omitempty"`
	Color       int     `json:"color,omitempty"`
	Fields      []Field `json:"fields,omitempty"`
	Timestamp   string  `json:"timestamp,omitempty"`
	Footer      *Footer `json:"footer,omitempty"`
}

Embed represents a Discord embed object.

type Field

type Field struct {
	Name   string `json:"name"`
	Value  string `json:"value"`
	Inline bool   `json:"inline,omitempty"`
}

Field represents a field in a Discord embed.

type Footer struct {
	Text string `json:"text"`
}

Footer represents the footer of a Discord embed.

type GatewayMessage

type GatewayMessage struct {
	ID        string `json:"id"`
	ChannelID string `json:"channel_id"`
	GuildID   string `json:"guild_id,omitempty"`
	Content   string `json:"content"`
	Author    *User  `json:"author,omitempty"`
	// WebhookID is set on messages posted through a webhook — including a
	// SolidPing legacy-webhook alert, which must never be ingested as a comment
	// on itself.
	WebhookID string `json:"webhook_id,omitempty"`
	// Type distinguishes a real user message from a system notice (a pin, a
	// "started a thread" marker, a join message).
	Type int `json:"type"`
}

GatewayMessage is the subset of a MESSAGE_CREATE payload we act on.

func (*GatewayMessage) IsHumanMessage

func (m *GatewayMessage) IsHumanMessage() bool

IsHumanMessage reports whether this message is something a person typed.

type GatewayStatus

type GatewayStatus struct {
	Enabled         bool       `json:"enabled"`
	Connected       bool       `json:"connected"`
	LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"`
	LastError       string     `json:"lastError,omitempty"`
	GuildCount      int        `json:"guildCount"`
	// BotUserID is the bot's own user id as reported by the last READY.
	BotUserID string `json:"botUserId,omitempty"`
}

GatewayStatus is the snapshot returned by the operator status endpoint. It never contains the bot token.

type GatewaySupervisor

type GatewaySupervisor struct {
	// contains filtered or unexported fields
}

GatewaySupervisor holds a long-lived Discord Gateway (WebSocket) connection and routes inbound guild messages into the transport-agnostic command and comment paths.

It is the Discord counterpart of slack.SlackSocketSupervisor, and exists for the same reason: Discord has no HTTP event subscription for ordinary messages. Buttons and slash commands arrive over the HTTP interactions endpoint; everything a human TYPES — a thread reply that should become an incident comment, an `@SolidPing checks list` — only ever arrives here.

func NewGatewaySupervisor

func NewGatewaySupervisor(svc *Service, cfg *config.Config, log *slog.Logger) *GatewaySupervisor

NewGatewaySupervisor constructs a supervisor wired to svc and cfg. It does not auto-start — call Run from a goroutine tracked by the caller.

func (*GatewaySupervisor) BotUserID

func (g *GatewaySupervisor) BotUserID() string

BotUserID returns the bot's own user id from the last READY.

func (*GatewaySupervisor) GetStatus

func (g *GatewaySupervisor) GetStatus() GatewayStatus

GetStatus returns a snapshot. Safe to call concurrently with Run.

func (*GatewaySupervisor) Run

func (g *GatewaySupervisor) Run(ctx context.Context) error

Run is the supervisor goroutine. Blocks until ctx is canceled.

type Guild

type Guild struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

Guild is the subset of a guild object we need.

type Handler

type Handler struct {
	base.HandlerBase
	// contains filtered or unexported fields
}

Handler provides the HTTP handlers for the Discord integration.

func NewHandler

func NewHandler(service *Service, cfg *config.Config) *Handler

NewHandler creates a new Discord integration handler.

func (*Handler) BuildInstallURLForOrg

func (h *Handler) BuildInstallURLForOrg(writer http.ResponseWriter, req *http.Request) error

BuildInstallURLForOrg mints a Discord bot install URL scoped to the authenticated caller's organization.

There is deliberately NO unauthenticated install entry point (the Slack package has one for its Marketplace listing). An anonymous Discord install would have to trust a caller-supplied org, which is precisely the hole spec 2026-07-05-01 closed on the Slack side.

POST /api/v1/orgs/:org/integrations/discord/install-url Body (optional): { "channelUid": "<uid>" }.

func (*Handler) GetDestinations

func (h *Handler) GetDestinations(writer http.ResponseWriter, req *http.Request) error

GetDestinations returns the Discord text channels available for an integration.

Route: GET /api/v1/orgs/:org/channels/:uid/discord/destinations.

func (*Handler) GetGatewayStatus

func (h *Handler) GetGatewayStatus(writer http.ResponseWriter, _ *http.Request) error

GetGatewayStatus returns the Discord Gateway supervisor status.

Route: GET /api/v1/integrations/discord/gateway/status.

func (*Handler) HandleInteractions

func (h *Handler) HandleInteractions(writer http.ResponseWriter, req *http.Request) error

HandleInteractions is Discord's single inbound callback for buttons and application commands.

Route: POST /api/v1/integrations/discord/interactions (behind VerifyMiddleware).

func (*Handler) OAuthCallback

func (h *Handler) OAuthCallback(writer http.ResponseWriter, req *http.Request) error

OAuthCallback completes a bot install and bounces the operator back into the dashboard on the integration that was just created or updated.

GET /api/v1/integrations/discord/oauth.

func (*Handler) SetGatewaySupervisor

func (h *Handler) SetGatewaySupervisor(sup *GatewaySupervisor)

SetGatewaySupervisor attaches a running Gateway supervisor so GetGatewayStatus can surface its state. Optional — call only on nodes that run it.

func (*Handler) VerifyMiddleware

func (h *Handler) VerifyMiddleware(next httpx.HandlerFunc) httpx.HandlerFunc

VerifyMiddleware verifies Discord's Ed25519 request signature.

Unlike the Slack middleware, this one does NOT fall through when no key is configured. Two reasons, and both are decisive:

  • The interactions endpoint is what acknowledges and escalates incidents. An unverified endpoint lets any caller on the internet acknowledge any incident whose uid they can guess or read off a screenshot.
  • Discord probes the endpoint with deliberately INVALID signatures during app setup and periodically afterwards, and DEACTIVATES the endpoint if a probe is answered with anything other than 401. An implementation that "skips verification when unconfigured" answers those probes 200 and gets the app's interactions turned off.

So a missing public key means "reject everything", not "trust everything".

type IncidentService

type IncidentService interface {
	// guildID names the guild the press happened in, so the ack-notice fan-out
	// can skip the message that already shows the acknowledgment.
	AcknowledgeIncidentFromDiscord(
		ctx context.Context, orgUID, incidentUID, discordUserID, discordUsername, guildID string,
	) (*models.Incident, error)
	GetIncidentByUID(ctx context.Context, orgUID, incidentUID string) (*models.Incident, error)
	GetCheckByUID(ctx context.Context, orgUID, checkUID string) (*models.Check, error)
	AddCommentFromDiscord(
		ctx context.Context, orgUID, incidentUID, text, discordUserID, discordUserName, guildID, messageID string,
	) (*models.Event, error)
	AddCommentFromDiscordCommand(
		ctx context.Context, orgUID, incidentUID, text, discordUserID, discordUserName, guildID string,
	) (*models.Event, error)
}

IncidentService is the subset of the incidents service the Discord integration needs. Declared here (rather than imported) so the dependency points one way — handlers/incidents must never import this package.

type InstallResult

type InstallResult struct {
	ConnectionUID string
	OrgSlug       string
	GuildID       string
	GuildName     string
}

InstallResult is what a completed bot install produced.

type Interaction

type Interaction struct {
	ID   string `json:"id"`
	Type int    `json:"type"`

	GuildID   string `json:"guild_id,omitempty"`
	ChannelID string `json:"channel_id,omitempty"`
	// Channel carries the invoking channel object; its `type` is what tells us
	// the command was typed inside a thread.
	Channel *ChannelInfo `json:"channel,omitempty"`

	// Member is present for a guild interaction; User for a DM.
	Member *InteractionMember `json:"member,omitempty"`
	User   *User              `json:"user,omitempty"`

	Message *InteractionMessage `json:"message,omitempty"`
	Data    *InteractionData    `json:"data,omitempty"`

	Token string `json:"token,omitempty"`
}

Interaction is an inbound Discord interaction (button press or application command). Only the fields SolidPing acts on are modeled.

func (*Interaction) InvokerID

func (i *Interaction) InvokerID() string

InvokerID returns the Discord user id behind the interaction.

func (*Interaction) InvokerName

func (i *Interaction) InvokerName() string

InvokerName returns a human label for the invoker.

type InteractionData

type InteractionData struct {
	// Component interactions.
	CustomID      string `json:"custom_id,omitempty"`
	ComponentType int    `json:"component_type,omitempty"`

	// Application commands.
	Name    string              `json:"name,omitempty"`
	Options []InteractionOption `json:"options,omitempty"`
}

InteractionData is the payload of a component or application-command interaction.

type InteractionMember

type InteractionMember struct {
	User *User  `json:"user,omitempty"`
	Nick string `json:"nick,omitempty"`
}

InteractionMember wraps the guild member who triggered the interaction.

type InteractionMessage

type InteractionMessage struct {
	ID        string `json:"id"`
	ChannelID string `json:"channel_id,omitempty"`
}

InteractionMessage is the message a component interaction was attached to.

type InteractionOption

type InteractionOption struct {
	Name    string              `json:"name"`
	Type    int                 `json:"type"`
	Value   any                 `json:"value,omitempty"`
	Options []InteractionOption `json:"options,omitempty"`
}

InteractionOption is one application-command option. Discord nests subcommands as options of type 1 (SUB_COMMAND) / 2 (SUB_COMMAND_GROUP).

type InteractionResponse

type InteractionResponse struct {
	Type int                      `json:"type"`
	Data *InteractionResponseData `json:"data,omitempty"`
}

InteractionResponse is what we answer an interaction with.

func DispatchInteraction

func DispatchInteraction(
	ctx context.Context, svc *Service, interaction *Interaction,
) (InteractionResponse, error)

DispatchInteraction is the transport-agnostic entry for Discord interactions.

type InteractionResponseData

type InteractionResponseData struct {
	Content         string           `json:"content,omitempty"`
	Embeds          []Embed          `json:"embeds,omitempty"`
	Components      []Component      `json:"components"`
	Flags           int              `json:"flags,omitempty"`
	AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"`
}

InteractionResponseData is the message body of an interaction response.

The `omitempty` choices here are load-bearing, because an UPDATE_MESSAGE (callback type 7) leaves every OMITTED field unchanged and replaces every present one:

  • Components carries NO omitempty on purpose. Clearing the Acknowledge row off a handled incident means sending `"components": []`, and a zero-length slice under omitempty is dropped by encoding/json — so the buttons would survive the acknowledgement and a second responder could press Acknowledge on an already-handled incident. This mirrors Message.Components on the REST edit path.
  • Embeds KEEPS omitempty for the opposite reason: an ack update carries no embeds, and omitting the key preserves the incident card the channel is reading. Sending `[]` here would erase it.
  • Content and Flags keep omitempty: nothing ever needs to blank a message's text or clear its flags, and on a fresh reply the zero values are the defaults anyway.

type Message

type Message struct {
	Content         string           `json:"content,omitempty"`
	Embeds          []Embed          `json:"embeds,omitempty"`
	Components      []Component      `json:"components"`
	AllowedMentions *AllowedMentions `json:"allowed_mentions,omitempty"`
}

Message is an outbound bot message (REST `POST /channels/{id}/messages` and `PATCH .../{message_id}`).

type MessageResult

type MessageResult struct {
	ID        string `json:"id"`
	ChannelID string `json:"channel_id"`
}

MessageResult is the subset of a created/fetched message we keep.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service provides business logic for the Discord bot integration.

func NewService

func NewService(
	dbService db.Service,
	cfg *config.Config,
	checksService *checks.Service,
	incidentsService IncidentService,
) *Service

NewService creates a new Discord integration service.

func (*Service) BuildInstallURL

func (s *Service) BuildInstallURL(ctx context.Context, channelUID, orgSlug string) (string, error)

BuildInstallURL mints a CSRF state and returns the Discord bot authorization URL. channelUID/orgSlug are stashed in the state so the callback can update the specific integration the install was triggered from.

func (*Service) BuildOrgInstallURL

func (s *Service) BuildOrgInstallURL(ctx context.Context, orgUID, orgSlug, channelUID string) (string, error)

BuildOrgInstallURL is the authenticated, org-scoped counterpart to BuildInstallURL: orgUID/orgSlug come from the verified route context, never from client input, so install targeting cannot be forged. When channelUID is set it must exist, be a Discord integration, and belong to orgUID.

func (*Service) CountInstalledGuilds

func (s *Service) CountInstalledGuilds(ctx context.Context) (int, error)

CountInstalledGuilds returns how many distinct guilds have the bot.

func (*Service) CreateCheck

func (s *Service) CreateCheck(ctx context.Context, guildID, target string) (*CreateCheckResult, error)

CreateCheck creates an HTTP check for the org a guild maps to.

func (*Service) GetClient

func (s *Service) GetClient(_ context.Context, _ string) (*BotClient, error)

GetClient returns a bot client for a guild's connection.

func (*Service) GetConnectionByGuildID

func (s *Service) GetConnectionByGuildID(ctx context.Context, guildID string) (*models.Integration, error)

GetConnectionByGuildID resolves which Discord integration inbound traffic for a guild operates on.

Same two-step rule as Slack's GetConnectionByTeamID, and for the same reason: a guild may be connected to several orgs (one integration row per org), so exactly one place must decide which is authoritative.

  1. Home org: the org recorded in organization_providers for (discord, guild_id) — the mapping "Sign in with Discord" already writes. If that org has its own connection, use it.
  2. Deterministic fallback: the oldest connection for the guild across all orgs, with a warning, since routing is then ambiguous.

func (*Service) GetDestinations

func (s *Service) GetDestinations(
	ctx context.Context, orgSlug, channelUID string,
) (*DiscordDestinationsResponse, error)

GetDestinations lists the guild's postable text channels for the picker.

func (*Service) HandleGuildRemoved

func (s *Service) HandleGuildRemoved(ctx context.Context, guildID string) error

HandleGuildRemoved deletes every connection for a guild the bot was kicked from. Mirrors Slack's HandleAppUninstalled: all orgs' rows go, not just one, or the others keep rendering a stale "connected" integration.

func (*Service) HandleOAuthCallback

func (s *Service) HandleOAuthCallback(ctx context.Context, code, state string) (*InstallResult, error)

HandleOAuthCallback completes a bot install: validates the CSRF state, exchanges the code, resolves the organization and creates/updates the integration connection.

func (*Service) SetDefaultChannel

func (s *Service) SetDefaultChannel(
	ctx context.Context, guildID, channelID string, sendWelcome bool,
) error

SetDefaultChannel points a guild's integration at a channel, optionally posting a welcome message there. Used by the in-band `config default-channel` command.

func (*Service) SetSupport

func (s *Service) SetSupport(svc *support.Service)

SetSupport wires the support inbox after construction. Late injection so this package stays importable by the support wiring without an import cycle.

type ThreadMetadata

type ThreadMetadata struct {
	Archived            bool `json:"archived"`
	Locked              bool `json:"locked"`
	AutoArchiveDuration int  `json:"auto_archive_duration,omitempty"`
}

ThreadMetadata carries a thread's archive state.

type TokenResponse

type TokenResponse struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	Scope        string `json:"scope"`
	Guild        *Guild `json:"guild,omitempty"`
}

TokenResponse is Discord's OAuth2 token-exchange response. For a `bot` authorization it also carries the guild the bot was added to, which is the only place the install learns which server it just joined.

func ExchangeCode

func ExchangeCode(
	ctx context.Context, tokenURL, clientID, clientSecret, code, redirectURI string,
) (*TokenResponse, error)

ExchangeCode trades an authorization code for tokens. tokenURL is a parameter so tests can point it at an httptest stand-in and still exercise the real request shape.

type User

type User struct {
	ID         string `json:"id"`
	Username   string `json:"username"`
	GlobalName string `json:"global_name,omitempty"`
	Bot        bool   `json:"bot,omitempty"`
}

User is the subset of a Discord user object we need.

func FetchCurrentUser

func FetchCurrentUser(ctx context.Context, apiBaseURL, userAccessToken string) (*User, error)

FetchCurrentUser resolves the user behind a *user* access token (the `identify` scope). Used only to record who installed the bot.

func (*User) DisplayName

func (u *User) DisplayName() string

DisplayName prefers the global (display) name over the raw username.

type WebhookMessage

type WebhookMessage struct {
	Content   string  `json:"content,omitempty"`
	Username  string  `json:"username,omitempty"`
	AvatarURL string  `json:"avatar_url,omitempty"`
	Embeds    []Embed `json:"embeds,omitempty"`
}

WebhookMessage represents a Discord webhook message.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL