enhance: Discord integration parity with Slack + documentation (#620)

This commit is contained in:
Tom Boucher 2026-03-16 09:37:28 -04:00 committed by GitHub
parent 95849c46fd
commit d065964c4a
6 changed files with 412 additions and 7 deletions

View file

@ -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 |

131
docs/remote-questions.md Normal file
View file

@ -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

View file

@ -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",
);
});

View file

@ -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<RemoteDispatchResult> {
@ -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<void> {
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<RemoteAnswer | null> {
const reactions: Array<{ emoji: string; count: number }> = [];
for (const emoji of NUMBER_EMOJIS) {

View file

@ -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(" · ") },
};
});

View file

@ -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: {