From d065964c4a3641506ef5d1140a1e407b3127efb9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 09:37:28 -0400 Subject: [PATCH] enhance: Discord integration parity with Slack + documentation (#620) --- docs/README.md | 1 + docs/remote-questions.md | 131 ++++++++++ .../gsd/tests/remote-questions.test.ts | 228 +++++++++++++++++- .../remote-questions/discord-adapter.ts | 33 +++ .../extensions/remote-questions/format.ts | 18 +- .../extensions/remote-questions/manager.ts | 8 + 6 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 docs/remote-questions.md diff --git a/docs/README.md b/docs/README.md index 2fb1ee3c6..ce50fd528 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Welcome to the GSD documentation. This covers everything from getting started to | [Getting Started](./getting-started.md) | Installation, first run, and basic usage | | [Auto Mode](./auto-mode.md) | How autonomous execution works — the state machine, crash recovery, and steering | | [Commands Reference](./commands.md) | All commands, keyboard shortcuts, and CLI flags | +| [Remote Questions](./remote-questions.md) | Discord and Slack integration for headless auto-mode | | [Configuration](./configuration.md) | Preferences, model selection, git settings, and token profiles | | [Token Optimization](./token-optimization.md) | Token profiles, context compression, complexity routing, and adaptive learning (v2.17) | | [Cost Management](./cost-management.md) | Budget ceilings, cost tracking, projections, and enforcement modes | diff --git a/docs/remote-questions.md b/docs/remote-questions.md new file mode 100644 index 000000000..2f5ce2e29 --- /dev/null +++ b/docs/remote-questions.md @@ -0,0 +1,131 @@ +# Remote Questions + +Remote questions allow GSD to ask for user input via Slack or Discord when running in headless auto-mode. When GSD encounters a decision point that needs human input, it posts the question to your configured channel and polls for a response. + +## Setup + +### Discord + +``` +/gsd remote discord +``` + +The setup wizard: +1. Prompts for your Discord bot token +2. Validates the token against the Discord API +3. Lists servers the bot belongs to (or lets you pick) +4. Lists text channels in the selected server +5. Sends a test message to confirm permissions +6. Saves the configuration to `~/.gsd/preferences.md` + +**Bot requirements:** +- A Discord bot application with a token (from [Discord Developer Portal](https://discord.com/developers/applications)) +- Bot must be invited to the target server with these permissions: + - Send Messages + - Read Message History + - Add Reactions + - View Channel +- The `DISCORD_BOT_TOKEN` environment variable must be set (the setup wizard handles this) + +### Slack + +``` +/gsd remote slack +``` + +The setup wizard: +1. Prompts for your Slack bot token (`xoxb-...`) +2. Validates the token +3. Prompts for a channel ID +4. Sends a test message to confirm permissions +5. Saves the configuration + +**Bot requirements:** +- A Slack app with a bot token (from [Slack API](https://api.slack.com/apps)) +- Bot must be invited to the target channel +- Required scopes: `chat:write`, `reactions:read`, `channels:history` + +## Configuration + +Remote questions are configured in `~/.gsd/preferences.md`: + +```yaml +remote_questions: + channel: discord # or slack + channel_id: "1234567890123456789" + timeout_minutes: 5 # 1-30, default 5 + poll_interval_seconds: 5 # 2-30, default 5 +``` + +## How It Works + +1. GSD encounters a decision point during auto-mode +2. The question is posted to your configured channel as a rich embed (Discord) or Block Kit message (Slack) +3. GSD polls for a response at the configured interval +4. You respond by: + - **Reacting** with a number emoji (1️⃣, 2️⃣, etc.) for single-question prompts + - **Replying** to the message with a number (`1`), comma-separated numbers (`1,3`), or free text +5. GSD picks up the response and continues execution +6. On Discord, a ✅ reaction is added to the prompt message to confirm receipt + +### Response Formats + +**Single question:** +- React with a number emoji (Discord only, single-question prompts) +- Reply with a number: `2` +- Reply with free text (captured as a user note) + +**Multiple questions:** +- Reply with semicolons: `1;2;custom text` +- Reply with newlines (one answer per line) + +### Timeouts + +If no response is received within `timeout_minutes`, the prompt times out and GSD continues with a timeout result. The LLM handles timeouts according to the task context — typically by making a conservative default choice or pausing auto-mode. + +## Commands + +| Command | Description | +|---------|-------------| +| `/gsd remote` | Show remote questions menu and current status | +| `/gsd remote slack` | Set up Slack integration | +| `/gsd remote discord` | Set up Discord integration | +| `/gsd remote status` | Show current configuration and last prompt status | +| `/gsd remote disconnect` | Remove remote questions configuration | + +## Discord vs Slack Feature Comparison + +| Feature | Discord | Slack | +|---------|---------|-------| +| Rich message format | Embeds with fields | Block Kit | +| Reaction-based answers | ✅ (single-question) | ❌ | +| Thread-based replies | Message replies | Thread replies | +| Message URL in logs | ✅ | ✅ | +| Answer acknowledgement | ✅ reaction on receipt | Thread context | +| Multi-question support | Text replies (semicolons/newlines) | Text replies (semicolons/newlines) | +| Context source in prompt | ✅ (footer) | ❌ | +| Server/channel picker | ✅ (interactive) | Manual channel ID | +| Token validation | ✅ | ✅ | +| Test message on setup | ✅ | ✅ | + +## Troubleshooting + +### "Remote auth failed" +- Verify your bot token is correct and not expired +- For Discord: ensure the bot is still in the server +- For Slack: ensure the bot token starts with `xoxb-` + +### "Could not send to channel" +- Verify the bot has Send Messages permission in the target channel +- For Discord: check the bot's role permissions in Server Settings +- For Slack: ensure the bot is invited to the channel (`/invite @botname`) + +### No response detected +- Ensure you're **replying to** the prompt message (not posting a new message) +- For reactions: only number emojis (1️⃣-5️⃣) on single-question prompts are detected +- Check that `timeout_minutes` is long enough for your response time + +### Channel ID format +- **Slack:** 9-12 uppercase alphanumeric characters (e.g., `C0123456789`) +- **Discord:** 17-20 digit numeric snowflake ID (e.g., `1234567890123456789`) +- Enable Developer Mode in Discord (Settings → Advanced) to copy channel IDs diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 40dbe551c..850ca4274 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -1,9 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseSlackReply, parseDiscordResponse, formatForDiscord } from "../../remote-questions/format.ts"; import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; import { sanitizeError } from "../../remote-questions/manager.ts"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ id: "choice", @@ -153,3 +159,223 @@ test("sanitizeError preserves short safe messages", () => { assert.equal(sanitizeError("Connection refused"), "Connection refused"); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// Discord Parity Tests +// ═══════════════════════════════════════════════════════════════════════════ + +test("formatForDiscord includes context source in footer when present", () => { + const prompt = { + id: "test-1", + channel: "discord" as const, + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + context: { source: "auto-mode-dispatch" }, + questions: [{ + id: "q1", + header: "Confirm", + question: "Proceed?", + options: [ + { label: "Yes", description: "Continue" }, + { label: "No", description: "Stop" }, + ], + allowMultiple: false, + }], + }; + + const { embeds } = formatForDiscord(prompt); + assert.equal(embeds.length, 1); + assert.ok(embeds[0].footer?.text.includes("auto-mode-dispatch"), "footer should include context source"); +}); + +test("formatForDiscord omits source from footer when context is absent", () => { + const prompt = { + id: "test-2", + channel: "discord" as const, + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + questions: [{ + id: "q1", + header: "Choice", + question: "Pick one", + options: [ + { label: "A", description: "Alpha" }, + { label: "B", description: "Beta" }, + ], + allowMultiple: false, + }], + }; + + const { embeds } = formatForDiscord(prompt); + assert.ok(!embeds[0].footer?.text.includes("Source:"), "footer should not include Source when context absent"); +}); + +test("formatForDiscord multi-question footer includes question position", () => { + const prompt = { + id: "test-3", + channel: "discord" as const, + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + questions: [ + { + id: "q1", + header: "First", + question: "Pick", + options: [{ label: "A", description: "a" }], + allowMultiple: false, + }, + { + id: "q2", + header: "Second", + question: "Pick", + options: [{ label: "B", description: "b" }], + allowMultiple: false, + }, + ], + }; + + const { embeds } = formatForDiscord(prompt); + assert.equal(embeds.length, 2); + assert.ok(embeds[0].footer?.text.includes("1/2"), "first embed footer should show 1/2"); + assert.ok(embeds[1].footer?.text.includes("2/2"), "second embed footer should show 2/2"); +}); + +test("formatForDiscord single-question generates reaction emojis", () => { + const prompt = { + id: "test-4", + channel: "discord" as const, + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + questions: [{ + id: "q1", + header: "Pick", + question: "Choose", + options: [ + { label: "A", description: "a" }, + { label: "B", description: "b" }, + { label: "C", description: "c" }, + ], + allowMultiple: false, + }], + }; + + const { reactionEmojis } = formatForDiscord(prompt); + assert.equal(reactionEmojis.length, 3, "should generate 3 reaction emojis for 3 options"); + assert.equal(reactionEmojis[0], "1️⃣"); + assert.equal(reactionEmojis[1], "2️⃣"); + assert.equal(reactionEmojis[2], "3️⃣"); +}); + +test("formatForDiscord multi-question generates no reaction emojis", () => { + const prompt = { + id: "test-5", + channel: "discord" as const, + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + questions: [ + { + id: "q1", + header: "First", + question: "Pick", + options: [{ label: "A", description: "a" }], + allowMultiple: false, + }, + { + id: "q2", + header: "Second", + question: "Pick", + options: [{ label: "B", description: "b" }], + allowMultiple: false, + }, + ], + }; + + const { reactionEmojis } = formatForDiscord(prompt); + assert.equal(reactionEmojis.length, 0, "multi-question should not generate reaction emojis"); +}); + +test("parseDiscordResponse handles multi-question text reply via semicolons", () => { + const result = parseDiscordResponse([], "1;2", [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }, + { + id: "second", + header: "Second", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Gamma", description: "G" }, + { label: "Delta", description: "D" }, + ], + }, + ]); + + assert.deepEqual(result.answers.first.answers, ["Alpha"]); + assert.deepEqual(result.answers.second.answers, ["Delta"]); +}); + +test("parseDiscordResponse handles multiple reactions for allowMultiple question", () => { + const result = parseDiscordResponse( + [{ emoji: "1️⃣", count: 1 }, { emoji: "3️⃣", count: 1 }], + null, + [{ + id: "choice", + header: "Choice", + question: "Pick any", + allowMultiple: true, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + { label: "Gamma", description: "G" }, + ], + }], + ); + + assert.deepEqual(result.answers.choice.answers, ["Alpha", "Gamma"]); +}); + +test("DiscordAdapter source-level: acknowledgeAnswer method exists", () => { + const adapterSrc = readFileSync( + join(__dirname, "..", "..", "remote-questions", "discord-adapter.ts"), + "utf-8", + ); + assert.ok(adapterSrc.includes("async acknowledgeAnswer"), "should have acknowledgeAnswer method"); + assert.ok(adapterSrc.includes("✅"), "should use checkmark emoji for acknowledgement"); +}); + +test("DiscordAdapter source-level: resolves guild ID for message URLs", () => { + const adapterSrc = readFileSync( + join(__dirname, "..", "..", "remote-questions", "discord-adapter.ts"), + "utf-8", + ); + assert.ok(adapterSrc.includes("guildId"), "should track guild ID"); + assert.ok(adapterSrc.includes("guild_id"), "should read guild_id from channel info"); + assert.ok( + adapterSrc.includes("discord.com/channels/"), + "should construct message URL with guild/channel/message format", + ); +}); + +test("DiscordAdapter source-level: sendPrompt sets threadUrl in ref", () => { + const adapterSrc = readFileSync( + join(__dirname, "..", "..", "remote-questions", "discord-adapter.ts"), + "utf-8", + ); + assert.ok( + adapterSrc.includes("threadUrl: messageUrl"), + "sendPrompt should set threadUrl to the constructed message URL", + ); +}); diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 4c9a4960e..e2c66409f 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -12,6 +12,7 @@ const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; export class DiscordAdapter implements ChannelAdapter { readonly name = "discord" as const; private botUserId: string | null = null; + private guildId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -24,6 +25,17 @@ export class DiscordAdapter implements ChannelAdapter { const res = await this.discordApi("GET", "/users/@me"); if (!res.id) throw new Error("Discord auth failed: invalid token"); this.botUserId = String(res.id); + + // Resolve guild ID for message URL generation. + // The channel belongs to a guild — fetch channel info to discover it. + try { + const channelInfo = await this.discordApi("GET", `/channels/${this.channelId}`); + if (channelInfo.guild_id) { + this.guildId = String(channelInfo.guild_id); + } + } catch { + // Non-fatal — message URLs will be omitted if guild ID can't be resolved + } } async sendPrompt(prompt: RemotePrompt): Promise { @@ -46,12 +58,18 @@ export class DiscordAdapter implements ChannelAdapter { } } + // Build message URL if guild ID is available + const messageUrl = this.guildId + ? `https://discord.com/channels/${this.guildId}/${this.channelId}/${messageId}` + : undefined; + return { ref: { id: prompt.id, channel: "discord", messageId, channelId: this.channelId, + threadUrl: messageUrl, }, }; } @@ -67,6 +85,21 @@ export class DiscordAdapter implements ChannelAdapter { return this.checkReplies(prompt, ref); } + /** + * Acknowledge that an answer was received by adding a ✅ reaction to the + * original prompt message. Best-effort — failures are silently ignored. + */ + async acknowledgeAnswer(ref: RemotePromptRef): Promise { + try { + await this.discordApi( + "PUT", + `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent("✅")}/@me`, + ); + } catch { + // Best-effort — don't let acknowledgement failures affect the flow + } + } + private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { const reactions: Array<{ emoji: string; count: number }> = []; for (const emoji of NUMBER_EMOJIS) { diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index 1e03c637b..6dd61712e 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -69,18 +69,24 @@ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[] return `${emoji} **${opt.label}** — ${opt.description}`; }); - const footerText = supportsReactions - ? (q.allowMultiple - ? "Reply with comma-separated choices (`1,3`) or react with matching numbers" - : "Reply with a number or react with the matching number") - : `Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`; + const footerParts: string[] = []; + if (supportsReactions) { + footerParts.push(q.allowMultiple + ? "Reply with comma-separated choices (`1,3`) or react with matching numbers" + : "Reply with a number or react with the matching number"); + } else { + footerParts.push(`Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`); + } + if (prompt.context?.source) { + footerParts.push(`Source: ${prompt.context.source}`); + } return { title: q.header, description: q.question, color: 0x7c3aed, fields: [{ name: "Options", value: optionLines.join("\n") }], - footer: { text: footerText }, + footer: { text: footerParts.join(" · ") }, }; }); diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index f965a657c..47d438980 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -76,6 +76,14 @@ export async function tryRemoteQuestions( } markPromptAnswered(prompt.id, answer); + + // Acknowledge receipt with a ✅ on Discord (Slack threads are self-evident) + if (config.channel === "discord" && dispatch.ref) { + try { + await (adapter as import("./discord-adapter.js").DiscordAdapter).acknowledgeAnswer(dispatch.ref); + } catch { /* best-effort */ } + } + return { content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }], details: {