enhance: Discord integration parity with Slack + documentation (#620)
This commit is contained in:
parent
95849c46fd
commit
d065964c4a
6 changed files with 412 additions and 7 deletions
|
|
@ -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
131
docs/remote-questions.md
Normal 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
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(" · ") },
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue