Repository navigation
feat(channel): add native Slack channel integration - #42
Merged
Merged
Conversation
Adds Slack as a sixth channel type, following the same shape as the Telegram and
Zalo OA integrations: a client package, an inbound Events API service, an
outbound service drained through the channel message outbox, a third-party
handler, and the dashboard form fields.
Inbound
POST /api/third/slack/webhook[/:channel_id]
Answers Slack's url_verification handshake before any channel lookup, because
Slack sends it once while the endpoint is being configured and does not retry.
event_callback messages map onto the existing conversation model through
ExternalSourceSlack. Bot authors, bot_message subtypes and empty text are
dropped so the bot cannot talk to itself in a loop. The channel is resolved by
explicit id, then by team_id, then by the single enabled Slack channel.
Outbound
Agent and AI replies are queued by EnqueueSlackMessage and drained by cron every
five seconds, with the same immediate-dispatch goroutine, backoff and retry
ceiling as the other channels. Replies are posted with the inbound thread_ts so
an answer stays in the customer's thread instead of landing in the workspace
timeline. Text and HTML go out as content; an image is sent with its signed asset
URL; an attachment appends the signed URL.
Signing secret verification
Two changes here, both because the endpoint is unauthenticated by design.
A channel with a signing secret now rejects a delivery that arrives without the
X-Slack-Signature and X-Slack-Request-Timestamp headers. Previously the check
only ran when both headers were present, so omitting them bypassed verification
entirely: anyone who learned the webhook URL could post into a workspace
conversation and trigger paid AI replies. Slack always signs once a signing
secret exists, so a missing header means the sender is not Slack.
Request timestamps are now rejected outside a five minute window in either
direction. Slack's own verification guide requires this, and without it a
captured request replays indefinitely: the signature covers the timestamp and the
body, but nothing in it expires.
A channel with no signing secret still accepts deliveries, so this is not a
breaking change for an existing installation. The dashboard field explains what
leaving it empty costs.
Credentials
Everything is per channel: bot token, signing secret, app id, team id, team name
and a default channel. No environment variables are added, so .env.example is
untouched. The bot token is required when creating a channel, the same way
Telegram's is, because without it the channel can receive events but can never
reply.
Tests
internal/slack/client_test.go
threaded reply carries thread_ts, a top-level post omits it, ok:false is
surfaced as an error rather than treated as success, and input validation
internal/handlers/third/slack_handler_test.go
url_verification handshake, an unsigned event is rejected and creates no
customer identity, a correctly signed event resolves the sender
internal/services/slack_inbound_service_test.go
inbound to conversation to outbox; rejection of missing headers, a
signature without a timestamp, a timestamp without a signature, a wrong
signature and a non-numeric timestamp; replayed timestamps at ten minutes,
one hour and ten minutes in the future, each correctly signed for its own
timestamp; fresh and four-minute-old signatures still accepted
Not included
Slack's OAuth install flow, so the bot token is pasted rather than obtained by
authorization. Channel and DM history backfill is also not implemented: the
channel starts receiving from the moment the Events API subscription is live.
Contributor
|
@JOY This branch has caused a conflict. |
Resolve against upstream main through huabeitech#49: - union the Slack and Discord channel additions (dto config types, external source enum, outbox enqueue + dispatch, channel parse and validation, cron dispatch, routes registration, dashboard dialog and channel page) - keep upstream's ServerConfig security fields (trustedProxies, trustedPlatform, rateLimit) which merge in cleanly alongside this branch's Slack config - parseSlackChannelConfig no longer trims a WelcomeMessage field the Slack config never had - the dashboard dialog's EditForm/schema grow both slack and discord fields so one form serves every IM channel - messages locales gain both channel option sets
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
Slack as a sixth channel type, following the same shape as the Telegram and Zalo OA integrations: a client package, an inbound Events API service, an outbound service drained through the channel message outbox, a third-party handler, and the dashboard form fields.
Independent of #41 — both branch from
mainand touch the same integration points, so whichever merges second will need a small rebase.Inbound
POST /api/third/slack/webhook[/:channel_id]Answers Slack's
url_verificationhandshake before any channel lookup, because Slack sends it once while the endpoint is being configured and does not retry.event_callbackmessages map onto the existing conversation model throughExternalSourceSlack. Bot authors,bot_messagesubtypes and empty text are dropped so the bot cannot talk to itself in a loop.Outbound
Agent and AI replies are queued by
EnqueueSlackMessageand drained by cron every five seconds, with the same immediate-dispatch goroutine, backoff and retry ceiling as the other channels. Replies are posted with the inboundthread_ts, so an answer stays in the customer's thread instead of landing in the workspace timeline.Two security changes worth a look
The endpoint is unauthenticated by design, so both of these matter.
A missing signature is now a rejection. Previously verification ran only when both
X-Slack-SignatureandX-Slack-Request-Timestampwere present:Omitting the headers therefore bypassed the check entirely, and anyone who learned the webhook URL could post into a workspace conversation and trigger paid AI replies. Slack always signs once a signing secret exists, so a missing header means the sender is not Slack.
Timestamps outside a five minute window are rejected. Slack's own verification guide requires this. Without it a captured request replays indefinitely — the signature covers the timestamp and the body, but nothing in it expires.
A channel with no signing secret still accepts deliveries, so neither change breaks an existing installation. The dashboard field now says what leaving it empty costs.
Credentials
Everything is per channel: bot token, signing secret, app id, team id, team name, default channel. No environment variables are added, so
.env.exampleis untouched. The bot token is required when creating a channel, the same way Telegram's is, because without it the channel can receive events but can never reply.Tests
internal/slack/client_test.gothread_ts; a top-level post omits it;ok:falsesurfaces as an error rather than being treated as success; input validationinternal/handlers/third/slack_handler_test.gourl_verificationhandshake; an unsigned event is rejected and creates no customer identity; a signed event resolves the senderinternal/services/slack_inbound_service_test.goNote that
client_test.gois new — the Slack client had no test coverage before this.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_URLexist in the ambient environment, so they are unrelated.Not included
Slack's OAuth install flow, so the bot token is pasted rather than obtained by authorization. Channel and DM history backfill is also not implemented: a channel starts receiving from the moment the Events API subscription goes live.