feat(channel): add native Discord channel integration - #41
Merged
Merged
Conversation
Adds Discord as a sixth channel type, following the same shape as the Telegram
and Zalo OA integrations: a client package, an inbound webhook service, an
outbound service drained through the channel message outbox, a third-party
handler, and the dashboard form fields.
Inbound
POST /api/third/discord/webhook[/:channel_id]
Accepts both a bare interaction/webhook body and one nested under "message".
Author, message id, channel id, guild id, attachments and embeds are all read
from either level, so the same endpoint works with the payload shapes Discord and
common bridges emit. Bot authors and empty messages are dropped. An attachment
becomes a readable "[filename] url" line and an embed falls back to its
description or title, so a customer who sends only an image or a link still
produces a message an agent can act on.
Messages map onto the existing conversation model through ExternalSourceDiscord,
so a Discord user gets the same identity resolution, assignment, handoff and
ticket behaviour as every other channel.
Outbound
Agent and AI replies are queued by EnqueueDiscordMessage and drained by cron
every five seconds, with the same immediate-dispatch goroutine, backoff and
retry ceiling as the other channels. Text and HTML go out as content; an image
message is sent as an embed with the asset's signed URL; an attachment appends
its signed URL to the text. The reply target is the discord_channel_id recorded
on the last inbound message, falling back to a DM channel created from the
customer's Discord user id, so a conversation that started in a server stays in
that server.
Guild scoping
GuildID and ChannelScope are enforced on the way in. A bot invited to several
servers answers only in the guild the channel names, and a channel set to
dm_only ignores guild messages. Without this the two fields are stored
configuration that silently does nothing, and an operator has no way to tell why
a bot is answering somewhere it should not.
Credentials
A channel may carry its own bot token. When it does not, the deployment-wide
token is used, resolved from discord.botToken in config.yaml or DISCORD_BOT_TOKEN
in the environment. New DiscordConfig section plus AGENT_DESK_DISCORD_* and
DISCORD_* bindings, documented in .env.example.
Security
The webhook secret is compared with crypto/subtle.ConstantTimeCompare. A
byte-wise != leaks how much of the prefix matched through response timing, which
matters here because the endpoint is unauthenticated by design. Verification is
skipped only when no secret is configured on the channel, matching how the
Telegram integration treats its webhook secret.
Tests
internal/discord/client_test.go client against an httptest stub
internal/handlers/third/discord_handler_test.go handler routing and response
internal/services/discord_inbound_service_test.go
inbound to conversation to outbox, guild scope matrix (matching guild,
other guild, DM under a guild-scoped channel, DM and guild message under
dm_only, unscoped channel), and rejection of a wrong webhook secret
internal/services/discord_integration_test.go full round trip
Not included
Discord's interactions endpoint signs requests with an Ed25519 signature over the
raw body, verified against the application public key. This integration ingests
through a shared-secret webhook instead, so the public key field is stored but
not yet used to verify anything. Adding Ed25519 verification, and the OAuth flow
that provisions a bot without a pasted token, are both worthwhile follow-ups.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Discord as a sixth channel type, following the same shape as the Telegram and Zalo OA integrations: a client package, an inbound webhook service, an outbound service drained through the channel message outbox, a third-party handler, and the dashboard form fields.
Inbound
POST /api/third/discord/webhook[/:channel_id]Accepts both a bare webhook body and one nested under
message, reading author, message id, channel id, guild id, attachments and embeds from either level. Bot authors and empty messages are dropped.An attachment becomes a readable
[filename] urlline, and an embed falls back to its description or title, so a customer who sends only an image or a link still produces a message an agent can act on.Messages map onto the existing conversation model through
ExternalSourceDiscord, so a Discord user gets the same identity resolution, assignment, handoff and ticket behaviour as every other channel.Outbound
Agent and AI replies are queued by
EnqueueDiscordMessageand drained by cron every five seconds, with the same immediate-dispatch goroutine, backoff and retry ceiling as the other channels. Text and HTML go out as content; an image message is sent as an embed carrying the asset's signed URL; an attachment appends its signed URL to the text.The reply target is the
discord_channel_idrecorded on the last inbound message, falling back to a DM channel created from the customer's Discord user id, so a conversation that started in a server stays in that server.Guild scoping
GuildIDandChannelScopeare enforced on the way in: a bot invited to several servers answers only in the guild the channel names, and a channel set todm_onlyignores guild messages. Without this the two fields are stored configuration that silently does nothing.Credentials
A channel may carry its own bot token. When it does not, the deployment-wide token is used, resolved from
discord.botTokeninconfig.yamlorDISCORD_BOT_TOKEN. NewDiscordConfigsection withAGENT_DESK_DISCORD_*andDISCORD_*bindings, documented in.env.example.Security note
The webhook secret is compared with
crypto/subtle.ConstantTimeCompare. A byte-wise!=leaks how much of the prefix matched through response timing, which matters on an endpoint that is unauthenticated by design.Verification is skipped only when the channel has no secret configured, matching the policy the Telegram integration already uses. For what it is worth,
telegram_inbound_service.go:43has the same timing exposure and would take the same two-line fix — happy to send that separately if useful.Tests
internal/discord/client_test.gohttpteststubinternal/handlers/third/discord_handler_test.gointernal/services/discord_inbound_service_test.gointernal/services/discord_integration_test.goThe guild scope matrix covers: matching guild accepted, other guild ignored, DM ignored by a guild-scoped channel, DM accepted by
dm_only, guild message ignored bydm_only, and an unscoped channel accepting both.Verified locally:
go build ./...,go vet ./internal/...,go test ./internal/...andtsc --noEmitall clean.TestLoadOverridesValuesFromEnvironmentandTestLoadFromDotEnvAndStandardEnvAliasesfail on my machine but also fail on a pristine checkout ofmainwhenPORT/DB_TYPE/DATABASE_URLare set in the ambient environment, so they are unrelated to this change.Not included
Discord's interactions endpoint signs requests with an Ed25519 signature over the raw body, verified against the application public key. This integration ingests through a shared-secret webhook instead, so the
publicKeyfield is stored but not yet used to verify anything. Adding Ed25519 verification, and an OAuth flow that provisions a bot without a pasted token, are both worthwhile follow-ups — say the word and I will send them.