Skip to content

Commit ea0910f

Browse files
committed
discordbot: deliver discount approvals via bot API so buttons render
Interactive components (the Approve button) are silently dropped by Discord when posted through a plain incoming webhook, so leadership only saw the embed text. Switch the discount-approval notification to the bot REST API. Generalize discordwebhook delivery: queue rows may now target a channel (POST /channels/{id}/messages, authenticated with the configured bot token) in addition to a webhook URL, via a new channel_id column and QueueChannelMessage. Any module or SQL trigger can post to any channel the bot can access by inserting a row. Replace the discount bot's Leadership Channel Webhook URL config with a Leadership Channel ID; the bot token is reused from the Discord bot config.
1 parent 409f1bd commit ea0910f

15 files changed

Lines changed: 183 additions & 77 deletions

File tree

e2e/pages_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -980,8 +980,8 @@ func (p *AdminDiscordConfigPage) CheckApprovalBotEnabled() {
980980
require.NoError(p.t, err)
981981
}
982982

983-
func (p *AdminDiscordConfigPage) FillApprovalBotWebhookURL(value string) {
984-
err := p.page.Locator("#leadership_channel_webhook_url").Fill(value)
983+
func (p *AdminDiscordConfigPage) FillLeadershipChannelID(value string) {
984+
err := p.page.Locator("#leadership_channel_id").Fill(value)
985985
require.NoError(p.t, err)
986986
}
987987

@@ -990,11 +990,11 @@ func (p *AdminDiscordConfigPage) FillApplicationPublicKey(value string) {
990990
require.NoError(p.t, err)
991991
}
992992

993-
func (p *AdminDiscordConfigPage) ExpectHasLeadershipWebhook() {
994-
locator := p.page.Locator("#leadership_channel_webhook_url")
995-
placeholder, err := locator.GetAttribute("placeholder")
993+
func (p *AdminDiscordConfigPage) ExpectLeadershipChannelID(value string) {
994+
locator := p.page.Locator("#leadership_channel_id")
995+
actual, err := locator.InputValue()
996996
require.NoError(p.t, err)
997-
require.Contains(p.t, placeholder, "secret is set")
997+
require.Equal(p.t, value, actual)
998998
}
999999

10001000
func (p *AdminDiscordConfigPage) ExpectApplicationPublicKey(value string) {

e2e/scenarios_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2729,7 +2729,7 @@ func TestAdmin_DiscordApprovalBotConfig(t *testing.T) {
27292729
// The approval bot fields render on the Discord page.
27302730
publicKey := strings.Repeat("ab", 32) // 64 hex chars => 32 bytes
27312731
configPage.CheckApprovalBotEnabled()
2732-
configPage.FillApprovalBotWebhookURL("https://discord.com/api/webhooks/leadership/secret")
2732+
configPage.FillLeadershipChannelID("123456789012345678")
27332733
configPage.FillApplicationPublicKey(publicKey)
27342734
configPage.Submit()
27352735

@@ -2740,20 +2740,20 @@ func TestAdmin_DiscordApprovalBotConfig(t *testing.T) {
27402740

27412741
// Verify the values landed in discord_config (not a separate table).
27422742
var enabled int
2743-
var webhook, pubKey string
2744-
err = env.db.QueryRow(`SELECT approval_bot_enabled, leadership_channel_webhook_url, application_public_key FROM discord_config ORDER BY version DESC LIMIT 1`).
2745-
Scan(&enabled, &webhook, &pubKey)
2743+
var channelID, pubKey string
2744+
err = env.db.QueryRow(`SELECT approval_bot_enabled, leadership_channel_id, application_public_key FROM discord_config ORDER BY version DESC LIMIT 1`).
2745+
Scan(&enabled, &channelID, &pubKey)
27462746
require.NoError(t, err)
27472747
assert.Equal(t, 1, enabled)
2748-
assert.Equal(t, "https://discord.com/api/webhooks/leadership/secret", webhook)
2748+
assert.Equal(t, "123456789012345678", channelID)
27492749
assert.Equal(t, publicKey, pubKey)
27502750

2751-
// Reload: the webhook is secret (shows placeholder), the public key is not.
2751+
// Reload: the channel ID and public key are not secret, so both round-trip.
27522752
configPage.Navigate()
27532753
err = page.WaitForLoadState()
27542754
require.NoError(t, err)
27552755

2756-
configPage.ExpectHasLeadershipWebhook()
2756+
configPage.ExpectLeadershipChannelID("123456789012345678")
27572757
configPage.ExpectApplicationPublicKey(publicKey)
27582758

27592759
enabledChecked, err := page.Locator("#approval_bot_enabled").IsChecked()

modules/discord/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ type Config struct {
2222
SyncIntervalHours int `json:"sync_interval_hours" config:"label=Full Reconciliation Interval (hours),section=sync,default=24,min=1,max=168,help=How often to fully reconcile all Discord role assignments. Default: 24 hours."`
2323

2424
// Discount Approval Bot
25-
ApprovalBotEnabled bool `json:"approval_bot_enabled" config:"label=Enabled,section=approvalbot,help=When on, a member requesting a discount posts a Discord message with an Approve button. Inbound interactions are still verified regardless."`
26-
LeadershipChannelWebhookURL string `json:"leadership_channel_webhook_url" config:"label=Leadership Channel Webhook URL,secret,section=approvalbot,help=Create a webhook on the leadership channel (Channel Settings → Integrations → Webhooks → New Webhook) and paste its URL here. Discount requests are posted here for approval."`
27-
ApplicationPublicKey string `json:"application_public_key" config:"label=Application Public Key,section=approvalbot,help=Hex-encoded Ed25519 public key from your Discord application's General Information page. Used to verify inbound button interactions."`
25+
ApprovalBotEnabled bool `json:"approval_bot_enabled" config:"label=Enabled,section=approvalbot,help=When on, a member requesting a discount posts a Discord message with an Approve button. Inbound interactions are still verified regardless."`
26+
LeadershipChannelID string `json:"leadership_channel_id" config:"label=Leadership Channel ID,section=approvalbot,help=Right-click the leadership channel in Discord (Developer Mode on) and choose Copy Channel ID. Discount requests are posted here by the bot. Requires the Bot Token above."`
27+
ApplicationPublicKey string `json:"application_public_key" config:"label=Application Public Key,section=approvalbot,help=Hex-encoded Ed25519 public key from your Discord application's General Information page. Used to verify inbound button interactions."`
2828
}
2929

3030
// Validate validates the Discord configuration.

modules/discord/config.templ

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ templ botSectionDescription() {
1818
}
1919

2020
templ approvalBotSectionDescription(selfURL string) {
21-
The discount approval bot posts a message with an <strong>Approve</strong> button to the leadership channel whenever a member requests a membership discount. To wire it up:
21+
The discount approval bot posts a message with an <strong>Approve</strong> button to the leadership channel whenever a member requests a membership discount. The message is sent through the bot using the <strong>Bot Token</strong> configured above, which is what lets Discord render the interactive button. To wire it up:
2222
<ol class="mb-0 mt-2">
23-
<li>Create a webhook on the leadership channel (Channel Settings → Integrations → Webhooks → New Webhook) and paste its URL into <strong>Leadership Channel Webhook URL</strong>.</li>
23+
<li>Make sure the <strong>Bot Token</strong> (Bot Configuration section) is set and the bot has permission to post in the leadership channel.</li>
24+
<li>Enable Developer Mode in Discord, right-click the leadership channel, choose <strong>Copy Channel ID</strong>, and paste it into <strong>Leadership Channel ID</strong>.</li>
2425
<li>In the <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>, open your application and copy the <strong>Public Key</strong> from General Information into <strong>Application Public Key</strong>.</li>
2526
<li>Set the application's <strong>Interactions Endpoint URL</strong> to <code>{ selfURL }/discord/interactions</code>. Enable the bot and save the correct public key first, or Discord's verification ping will fail.</li>
2627
</ol>

modules/discord/config_templ.go

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

modules/discord/module.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func New(db *sql.DB, self *url.URL, iss *engine.TokenIssuer, eventLogger *engine
107107
// Discount approval bot config (merged from the former discordbot config page).
108108
db.Exec("ALTER TABLE discord_config ADD COLUMN approval_bot_enabled INTEGER NOT NULL DEFAULT 0")
109109
db.Exec("ALTER TABLE discord_config ADD COLUMN leadership_channel_webhook_url TEXT NOT NULL DEFAULT ''")
110+
db.Exec("ALTER TABLE discord_config ADD COLUMN leadership_channel_id TEXT NOT NULL DEFAULT ''")
110111
db.Exec("ALTER TABLE discord_config ADD COLUMN application_public_key TEXT NOT NULL DEFAULT ''")
111112

112113
// Migrate legacy trigger_event-based webhooks that used the old member_events trigger.

modules/discordbot/README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Notifies leadership when a member requests a membership discount and lets any au
55
## Functionality
66

77
- AFTER UPDATE OF `discount_status` trigger on `members` enqueues a member ID into `discordbot_discount_request_queue` whenever `discount_status` transitions into `'requested'`. Nothing is enqueued on signup, on unrelated updates, or on the `requested``approved` transition.
8-
- A 15s polling worker drains the queue, builds a rich Discord payload (an embed describing the request plus a single **Approve** button), and forwards it via the `discordwebhook` module's `MessageQueuer` for rate-limited delivery.
8+
- A 15s polling worker drains the queue, builds a rich Discord payload (an embed describing the request plus a single **Approve** button), and forwards it via the `discordwebhook` module's `MessageQueuer.QueueChannelMessage` for rate-limited delivery. Delivery goes through the **bot REST API** (not an incoming webhook) because Discord only renders interactive components on messages posted by an application/bot.
99
- `POST /discord/interactions` receives Discord's signed callbacks. The handler verifies the Ed25519 signature, then atomically runs `UPDATE members SET discount_status='approved' WHERE id=? AND discount_status='requested'`, logs a `DiscountApprovedViaDiscord` audit event, and replies with `UPDATE_MESSAGE` (empty `components`) recording who approved and removing the button. If the request is no longer pending (the member withdrew it, or another leader already approved), it instead shows a "Discount request closed" message and changes nothing.
1010
- No Conway authentication is required: the route is unauthenticated and identity is established purely by Discord's request signature against the configured `ApplicationPublicKey`.
1111

@@ -21,15 +21,16 @@ Notifies leadership when a member requests a membership discount and lets any au
2121

2222
## Setup
2323

24-
1. Create a Discord application at <https://discord.com/developers/applications>.
25-
2. Copy the application's **Public Key** (hex) into the Conway admin UI under **Integrations → Discord → Discount Approval Bot → Application Public Key**.
26-
3. In the leadership channel, create a webhook and copy its URL into **Leadership Channel Webhook URL** (stored as a secret).
27-
4. In the Discord application's **General Information** page, set **Interactions Endpoint URL** to `https://<your-conway-host>/discord/interactions`. Discord will immediately probe the endpoint with a signed PING; saving succeeds only if signature verification passes.
28-
5. Toggle **Enabled** on.
24+
1. Create a Discord application at <https://discord.com/developers/applications>, add a **Bot** to it, and copy the **Bot Token** into the Conway admin UI under **Integrations → Discord → Bot Configuration → Bot Token**.
25+
2. Invite the bot to your server and give it permission to post in the leadership channel (the bot must be able to send messages there).
26+
3. Copy the application's **Public Key** (hex) into **Integrations → Discord → Discount Approval Bot → Application Public Key**.
27+
4. Enable Developer Mode in Discord, right-click the leadership channel, choose **Copy Channel ID**, and paste it into **Leadership Channel ID**.
28+
5. In the Discord application's **General Information** page, set **Interactions Endpoint URL** to `https://<your-conway-host>/discord/interactions`. Discord will immediately probe the endpoint with a signed PING; saving succeeds only if signature verification passes.
29+
6. Toggle **Enabled** on.
2930

3031
## Behavioral details
3132

32-
- The Discord application's bot account does not need to be invited to the server; webhook delivery posts the message, and interaction callbacks are routed by Discord's infrastructure based on the application's configured endpoint URL.
33+
- Unlike incoming-webhook delivery, the Approve button requires that the message be posted **by the bot**: the bot account must be in the server and able to post in the leadership channel. The notification is sent via `POST /channels/{LeadershipChannelID}/messages` authenticated with the configured **Bot Token**. Interaction callbacks are routed by Discord's infrastructure based on the application's configured endpoint URL.
3334
- Inbound interactions must be acknowledged within 3 seconds, so the entire happy path (signature verify, DB lookup, UPDATE, response build) runs inline on the request goroutine.
3435
- Approval is atomic via the `WHERE ... AND discount_status='requested'` clause, so two leaders clicking Approve at once cannot double-approve; the loser sees "Discount request closed".
3536
- **Family** requests can be approved from Discord like any other tier, but the root-account linkage must still be completed in the admin panel; the request and approval messages call this out.

modules/discordbot/config.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import (
77

88
// Config controls the Discord discount-approval bot.
99
//
10-
// LeadershipChannelWebhookURL is the Discord webhook URL used to POST a
11-
// notification when a member requests a membership discount. The message
12-
// contains an Approve button that any leader in the channel can click to
13-
// approve the request.
10+
// LeadershipChannelID is the ID of the Discord channel where a notification is
11+
// posted when a member requests a membership discount. The message is sent via
12+
// the Discord bot REST API (authenticated with the configured bot token) so it
13+
// can carry an Approve button that any leader in the channel can click.
1414
//
1515
// ApplicationPublicKey is the hex-encoded Ed25519 public key shown on the
1616
// Discord application's "General Information" page. Discord signs every
@@ -19,19 +19,20 @@ import (
1919
//
2020
// To wire this up in Discord:
2121
//
22-
// 1. Create a webhook on the leadership channel (Channel Settings →
23-
// Integrations → Webhooks → New Webhook), copy its URL into
24-
// LeadershipChannelWebhookURL.
25-
// 2. In the Discord Developer Portal, open your application and copy the
22+
// 1. Create a bot for your application, give it permission to post in the
23+
// leadership channel, and copy its Bot Token into the Discord bot config.
24+
// 2. Enable Developer Mode in Discord, right-click the leadership channel,
25+
// choose "Copy Channel ID", and paste it into LeadershipChannelID.
26+
// 3. In the Discord Developer Portal, open your application and copy the
2627
// "Public Key" from General Information into ApplicationPublicKey.
27-
// 3. Set "Interactions Endpoint URL" to
28+
// 4. Set "Interactions Endpoint URL" to
2829
// https://<your-conway-host>/discord/interactions. Discord will PING the
2930
// URL and refuse to save unless Conway responds correctly, so make sure
3031
// Enabled=true and the public key is correct first.
3132
type Config struct {
32-
Enabled bool `json:"enabled" config:"label=Enabled,help=When on, a member requesting a discount posts a Discord message with an Approve button. Inbound interactions are still verified regardless."`
33-
LeadershipChannelWebhookURL string `json:"leadership_channel_webhook_url" config:"label=Leadership Channel Webhook URL,secret,help=Create a webhook on the leadership channel (Channel Settings → Integrations → Webhooks → New Webhook) and paste its URL here. Discount requests are posted here for approval."`
34-
ApplicationPublicKey string `json:"application_public_key" config:"label=Application Public Key,help=Hex-encoded Ed25519 public key from your Discord application's General Information page. Used to verify inbound button interactions."`
33+
Enabled bool `json:"enabled" config:"label=Enabled,help=When on, a member requesting a discount posts a Discord message with an Approve button. Inbound interactions are still verified regardless."`
34+
LeadershipChannelID string `json:"leadership_channel_id" config:"label=Leadership Channel ID,help=Right-click the leadership channel in Discord (Developer Mode on) and choose Copy Channel ID. Discount requests are posted here by the bot."`
35+
ApplicationPublicKey string `json:"application_public_key" config:"label=Application Public Key,help=Hex-encoded Ed25519 public key from your Discord application's General Information page. Used to verify inbound button interactions."`
3536
}
3637

3738
// Validate ensures the public key is a valid Ed25519 key when set. Other

0 commit comments

Comments
 (0)