Skip to content

feat(channel): add native Discord channel integration - #41

Merged
bbbbbbbbbbbbba merged 1 commit into
huabeitech:mainfrom
DOS:feat/discord-channel
Sep 14, 2026
Merged

bbbbbbbbbbbbba merged 1 commit into
huabeitech:mainfrom
DOS:feat/discord-channel

Conversation

@JOY

@JOY JOY commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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] 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 carrying 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.

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. New DiscordConfig section with AGENT_DESK_DISCORD_* and DISCORD_* 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:43 has the same timing exposure and would take the same two-line fix — happy to send that separately if useful.

Tests

File Covers
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; wrong-secret rejection
internal/services/discord_integration_test.go full round trip

The 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 by dm_only, and an unscoped channel accepting both.

Verified locally: go build ./..., go vet ./internal/..., go test ./internal/... and tsc --noEmit all clean. TestLoadOverridesValuesFromEnvironment and TestLoadFromDotEnvAndStandardEnvAliases fail on my machine but also fail on a pristine checkout of main when PORT / DB_TYPE / DATABASE_URL are 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 publicKey field 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.

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.
@bbbbbbbbbbbbba
bbbbbbbbbbbbba merged commit 08810b6 into huabeitech:main Sep 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants