Documentation
¶
Overview ¶
Package discord provides a client for sending messages via Discord webhooks.
Index ¶
- Constants
- Variables
- func BuildCustomID(action, subject string) string
- func CommentDedupeStateKey(guildID, channelID, messageID string) string
- func IncidentLabel(incident *models.Incident) string
- func IsPostableChannelType(t int) bool
- func IsThreadChannelType(t int) bool
- func LookupThreadIncident(ctx context.Context, dbService db.Service, guildID, threadID string) (string, string, bool)
- func MentionsBot(text, botUserID string) bool
- func ParseChannelReference(ref string) string
- func ParseCustomID(customID string) (string, string)
- func ReverseThreadStateKey(guildID, threadID string) string
- func VerifySignature(key ed25519.PublicKey, timestamp string, body []byte, hexSignature string) bool
- type AllowedMentions
- type BotClient
- func (c *BotClient) CreateMessage(ctx context.Context, channelID string, msg *Message) (*MessageResult, error)
- func (c *BotClient) EditMessage(ctx context.Context, channelID, messageID string, msg *Message) error
- func (c *BotClient) GetChannel(ctx context.Context, channelID string) (*ChannelInfo, error)
- func (c *BotClient) GetCurrentUser(ctx context.Context) (*User, error)
- func (c *BotClient) GetGuild(ctx context.Context, guildID string) (*Guild, error)
- func (c *BotClient) ListGuildChannels(ctx context.Context, guildID string) ([]ChannelInfo, error)
- func (c *BotClient) StartThreadFromMessage(ctx context.Context, channelID, messageID, name string) (*ChannelInfo, error)
- func (c *BotClient) UnarchiveThread(ctx context.Context, threadID string) error
- func (c *BotClient) WithBaseURL(base string) *BotClient
- type ChannelInfo
- type Client
- type Command
- type CommandResponse
- type Component
- type CreateCheckResult
- type DiscordChannel
- type DiscordDestinationsResponse
- type Embed
- type Field
- type Footer
- type GatewayMessage
- type GatewayStatus
- type GatewaySupervisor
- type Guild
- type Handler
- func (h *Handler) BuildInstallURLForOrg(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) GetDestinations(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) GetGatewayStatus(writer http.ResponseWriter, _ *http.Request) error
- func (h *Handler) HandleInteractions(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) OAuthCallback(writer http.ResponseWriter, req *http.Request) error
- func (h *Handler) SetGatewaySupervisor(sup *GatewaySupervisor)
- func (h *Handler) VerifyMiddleware(next httpx.HandlerFunc) httpx.HandlerFunc
- type IncidentService
- type InstallResult
- type Interaction
- type InteractionData
- type InteractionMember
- type InteractionMessage
- type InteractionOption
- type InteractionResponse
- type InteractionResponseData
- type Message
- type MessageResult
- type Service
- func (s *Service) BuildInstallURL(ctx context.Context, channelUID, orgSlug string) (string, error)
- func (s *Service) BuildOrgInstallURL(ctx context.Context, orgUID, orgSlug, channelUID string) (string, error)
- func (s *Service) CountInstalledGuilds(ctx context.Context) (int, error)
- func (s *Service) CreateCheck(ctx context.Context, guildID, target string) (*CreateCheckResult, error)
- func (s *Service) GetClient(_ context.Context, _ string) (*BotClient, error)
- func (s *Service) GetConnectionByGuildID(ctx context.Context, guildID string) (*models.Integration, error)
- func (s *Service) GetDestinations(ctx context.Context, orgSlug, channelUID string) (*DiscordDestinationsResponse, error)
- func (s *Service) HandleGuildRemoved(ctx context.Context, guildID string) error
- func (s *Service) HandleOAuthCallback(ctx context.Context, code, state string) (*InstallResult, error)
- func (s *Service) SetDefaultChannel(ctx context.Context, guildID, channelID string, sendWelcome bool) error
- func (s *Service) SetSupport(svc *support.Service)
- type ThreadMetadata
- type TokenResponse
- type User
- type WebhookMessage
Constants ¶
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.
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.
const ( ComponentTypeActionRow = 1 ComponentTypeButton = 2 )
Message component types (Discord "message components").
const ( ButtonStylePrimary = 1 ButtonStyleSecondary = 2 ButtonStyleSuccess = 3 ButtonStyleDanger = 4 ButtonStyleLink = 5 )
Button styles.
const ( InteractionTypePing = 1 InteractionTypeApplicationCommand = 2 InteractionTypeMessageComponent = 3 InteractionTypeAutocomplete = 4 InteractionTypeModalSubmit = 5 )
Interaction types delivered to the interactions endpoint.
const ( InteractionCallbackPong = 1 InteractionCallbackChannelMessage = 4 InteractionCallbackDeferredChannelMsg = 5 InteractionCallbackDeferredUpdateMsg = 6 InteractionCallbackUpdateMessage = 7 InteractionCallbackAutocompleteResponse = 8 )
Interaction callback (response) types.
const ( // ActionAcknowledge acknowledges an incident. ActionAcknowledge = "ack" 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.
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).
const ( ColorRed = 16711680 // #FF0000 - Active/reopened incidents ColorGreen = 65280 // #00FF00 - Resolved incidents ColorOrange = 16744448 // #FFA500 - Escalations ColorBlue = 3447003 // #3498DB - Info )
Discord embed colors.
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 )
const DefaultGatewayURL = "wss://gateway.discord.gg/?v=10&encoding=json"
DefaultGatewayURL is Discord's public Gateway entry point.
const ( // DefaultTimeout is the default HTTP client timeout. DefaultTimeout = 30 * time.Second )
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 ¶
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") )
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.
var ErrBotTokenMissing = errors.New("discord bot token not configured")
ErrBotTokenMissing is returned when a bot call is attempted with no token.
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 ¶
BuildCustomID packs an action and its subject into a component custom id.
func CommentDedupeStateKey ¶
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 ¶
IncidentLabel renders "#42 (api is down)", degrading gracefully for an incident created before the per-org numbers existed.
func IsPostableChannelType ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
GetChannel fetches one channel (or thread) by id.
func (*BotClient) GetCurrentUser ¶
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) ListGuildChannels ¶
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 ¶
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 ¶
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 (*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 ¶
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 ¶
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 ¶
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 ¶
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"`
}
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 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 ¶
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.
type Handler ¶
type Handler struct {
base.HandlerBase
// contains filtered or unexported fields
}
Handler provides the HTTP handlers for the Discord integration.
func NewHandler ¶
NewHandler creates a new Discord integration handler.
func (*Handler) BuildInstallURLForOrg ¶
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 ¶
GetDestinations returns the Discord text channels available for an integration.
Route: GET /api/v1/orgs/:org/channels/:uid/discord/destinations.
func (*Handler) GetGatewayStatus ¶
GetGatewayStatus returns the Discord Gateway supervisor status.
Route: GET /api/v1/integrations/discord/gateway/status.
func (*Handler) HandleInteractions ¶
HandleInteractions is Discord's single inbound callback for buttons and application commands.
Route: POST /api/v1/integrations/discord/interactions (behind VerifyMiddleware).
func (*Handler) OAuthCallback ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) 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.
- 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.
- 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 ¶
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 ¶
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 ¶
FetchCurrentUser resolves the user behind a *user* access token (the `identify` scope). Used only to record who installed the bot.
func (*User) DisplayName ¶
DisplayName prefers the global (display) name over the raw username.