feat: remote user questions via Slack/Discord for headless auto-mode
When ask_user_questions is called in non-interactive mode (ctx.hasUI = false), transparently route questions to a configured Slack or Discord channel and poll for the user's response. Same tool interface, automatic routing. - Add adapter pattern for Slack (Bot Token API) and Discord (HTTP API) - Add /gsd remote command for interactive setup wizard - Add SLACK_BOT_TOKEN / DISCORD_BOT_TOKEN to wizard and env hydration - Add remote_questions config to GSD preferences with merge support - Fix parseScalar to preserve large numeric IDs (Discord channel IDs) - Show remote channel status on session_start Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e1ddd51f2
commit
45fdf5d54d
12 changed files with 1469 additions and 8 deletions
|
|
@ -104,7 +104,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
],
|
||||
parameters: AskUserQuestionsParams,
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
// Validation
|
||||
if (params.questions.length === 0 || params.questions.length > 3) {
|
||||
return errorResult("Error: questions must contain 1-3 items", params.questions);
|
||||
|
|
@ -120,6 +120,9 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
const { tryRemoteQuestions } = await import("./remote-questions/index.js");
|
||||
const remoteResult = await tryRemoteQuestions(params.questions, signal);
|
||||
if (remoteResult) return remoteResult;
|
||||
return errorResult("Error: UI not available (non-interactive mode)", params.questions);
|
||||
}
|
||||
|
||||
|
|
@ -165,19 +168,53 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as AskUserQuestionsDetails | undefined;
|
||||
const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string }) | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
|
||||
// Remote channel result
|
||||
if (details.remote) {
|
||||
if (details.timed_out) {
|
||||
const channelLabel = details.channel ?? "remote";
|
||||
return new Text(
|
||||
`${theme.fg("warning", `${channelLabel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const remoteResponse = details.response as import("./remote-questions/channels.js").RemoteAnswer | undefined;
|
||||
const questions = (details.questions ?? []) as Question[];
|
||||
const lines: string[] = [];
|
||||
const channelLabel = details.channel ?? "remote";
|
||||
lines.push(theme.fg("dim", channelLabel));
|
||||
if (remoteResponse) {
|
||||
for (const q of questions) {
|
||||
const answer = remoteResponse.answers[q.id];
|
||||
if (!answer) {
|
||||
lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`);
|
||||
continue;
|
||||
}
|
||||
const answerText = answer.answers.length > 0 ? answer.answers.join(", ") : "(custom)";
|
||||
let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`;
|
||||
if (answer.user_note) {
|
||||
line += ` ${theme.fg("muted", `[note: ${answer.user_note}]`)}`;
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
if (details.cancelled || !details.response) {
|
||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const q of details.questions) {
|
||||
const answer = details.response.answers[q.id];
|
||||
const answer = (details.response as RoundResult).answers[q.id];
|
||||
if (!answer) {
|
||||
lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "./doctor.js";
|
||||
import { loadPrompt } from "./prompt-loader.js";
|
||||
import { handleMigrate } from "./migrate/command.js";
|
||||
import { handleRemote } from "../remote-questions/remote-command.js";
|
||||
|
||||
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
|
||||
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
|
||||
|
|
@ -52,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
|
|||
|
||||
export function registerGSDCommand(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("gsd", {
|
||||
description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate",
|
||||
description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate|remote",
|
||||
|
||||
getArgumentCompletions: (prefix: string) => {
|
||||
const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
|
||||
const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"];
|
||||
const parts = prefix.trim().split(/\s+/);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
|
|
@ -78,6 +79,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
.map((cmd) => ({ value: `prefs ${cmd}`, label: cmd }));
|
||||
}
|
||||
|
||||
if (parts[0] === "remote" && parts.length <= 2) {
|
||||
const subPrefix = parts[1] ?? "";
|
||||
return ["slack", "discord", "status", "disconnect"]
|
||||
.filter((cmd) => cmd.startsWith(subPrefix))
|
||||
.map((cmd) => ({ value: `remote ${cmd}`, label: cmd }));
|
||||
}
|
||||
|
||||
if (parts[0] === "doctor") {
|
||||
const modePrefix = parts[1] ?? "";
|
||||
const modes = ["fix", "heal", "audit"];
|
||||
|
|
@ -142,13 +150,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "remote" || trimmed.startsWith("remote ")) {
|
||||
await handleRemote(trimmed.replace(/^remote\s*/, "").trim(), ctx, pi);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
await showSmartEntry(ctx, pi, process.cwd());
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
|
||||
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
|
||||
"warning",
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export default function (pi: ExtensionAPI) {
|
|||
});
|
||||
pi.registerTool(dynamicBash as any);
|
||||
|
||||
// ── session_start: render branded GSD header ───────────────────────────
|
||||
// ── session_start: render branded GSD header + remote channel status ──
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
const version = process.env.GSD_VERSION || "0.0.0";
|
||||
|
|
@ -82,6 +82,17 @@ export default function (pi: ExtensionAPI) {
|
|||
|
||||
const headerContent = `${logoText}\n${titleLine}`;
|
||||
ctx.ui.setHeader((_ui, _theme) => new Text(headerContent, 1, 0));
|
||||
|
||||
// Notify remote questions status if configured
|
||||
try {
|
||||
const { getRemoteConfigStatus } = await import("../remote-questions/config.js");
|
||||
const status = getRemoteConfigStatus();
|
||||
if (!status.includes("not configured")) {
|
||||
ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info");
|
||||
}
|
||||
} catch {
|
||||
// Remote questions module not available — ignore
|
||||
}
|
||||
});
|
||||
|
||||
// ── Ctrl+Alt+G shortcut — GSD dashboard overlay ────────────────────────
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ export interface AutoSupervisorConfig {
|
|||
hard_timeout_minutes?: number;
|
||||
}
|
||||
|
||||
export interface RemoteQuestionsConfig {
|
||||
channel: "slack" | "discord";
|
||||
channel_id: string;
|
||||
timeout_minutes?: number; // Default: 5
|
||||
poll_interval_seconds?: number; // Default: 5
|
||||
}
|
||||
|
||||
export interface GSDPreferences {
|
||||
version?: number;
|
||||
always_use_skills?: string[];
|
||||
|
|
@ -43,6 +50,7 @@ export interface GSDPreferences {
|
|||
auto_supervisor?: AutoSupervisorConfig;
|
||||
uat_dispatch?: boolean;
|
||||
budget_ceiling?: number;
|
||||
remote_questions?: RemoteQuestionsConfig;
|
||||
}
|
||||
|
||||
export interface LoadedGSDPreferences {
|
||||
|
|
@ -430,7 +438,12 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
|
|||
function parseScalar(value: string): string | number | boolean {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
if (/^-?\d+$/.test(value)) return Number(value);
|
||||
if (/^-?\d+$/.test(value)) {
|
||||
const n = Number(value);
|
||||
// Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
|
||||
if (Number.isSafeInteger(n)) return n;
|
||||
return value;
|
||||
}
|
||||
return value.replace(/^['\"]|['\"]$/g, "");
|
||||
}
|
||||
|
||||
|
|
@ -495,6 +508,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
|||
auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) },
|
||||
uat_dispatch: override.uat_dispatch ?? base.uat_dispatch,
|
||||
budget_ceiling: override.budget_ceiling ?? base.budget_ceiling,
|
||||
remote_questions: override.remote_questions
|
||||
? { ...(base.remote_questions ?? {}), ...override.remote_questions }
|
||||
: base.remote_questions,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
36
src/resources/extensions/remote-questions/channels.ts
Normal file
36
src/resources/extensions/remote-questions/channels.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Remote Questions — Adapter pattern interfaces
|
||||
*
|
||||
* Defines the contract for Slack/Discord (or any future) channel adapters.
|
||||
*/
|
||||
|
||||
export interface ChannelAdapter {
|
||||
readonly name: string;
|
||||
sendQuestions(questions: FormattedQuestion[]): Promise<SendResult>;
|
||||
pollResponse(ref: PollReference): Promise<RemoteAnswer | null>;
|
||||
validate(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormattedQuestion {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
allowMultiple: boolean;
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
ref: PollReference;
|
||||
threadUrl?: string;
|
||||
}
|
||||
|
||||
export interface PollReference {
|
||||
channelType: "slack" | "discord";
|
||||
messageId: string;
|
||||
threadTs?: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface RemoteAnswer {
|
||||
answers: Record<string, { answers: string[]; user_note?: string }>;
|
||||
}
|
||||
78
src/resources/extensions/remote-questions/config.ts
Normal file
78
src/resources/extensions/remote-questions/config.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Remote Questions — Configuration resolution
|
||||
*
|
||||
* Reads remote_questions config from GSD preferences and verifies
|
||||
* the corresponding token exists in process.env.
|
||||
*/
|
||||
|
||||
import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js";
|
||||
|
||||
export interface ResolvedConfig {
|
||||
channel: "slack" | "discord";
|
||||
channelId: string;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const ENV_KEYS: Record<string, string> = {
|
||||
slack: "SLACK_BOT_TOKEN",
|
||||
discord: "DISCORD_BOT_TOKEN",
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MINUTES = 5;
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* Resolve remote questions configuration from preferences + env.
|
||||
* Returns null if not configured or token is missing.
|
||||
*/
|
||||
export function resolveRemoteConfig(): ResolvedConfig | null {
|
||||
const prefs = loadEffectiveGSDPreferences();
|
||||
const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
|
||||
if (!rq || !rq.channel || !rq.channel_id) return null;
|
||||
|
||||
const envVar = ENV_KEYS[rq.channel];
|
||||
if (!envVar) return null;
|
||||
|
||||
const token = process.env[envVar];
|
||||
if (!token) return null;
|
||||
|
||||
const timeoutMinutes = rq.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES;
|
||||
const pollIntervalSeconds = rq.poll_interval_seconds ?? DEFAULT_POLL_INTERVAL_SECONDS;
|
||||
|
||||
// Always coerce channel_id to string — parseScalar may convert large numeric
|
||||
// Discord IDs to a lossy Number (exceeds Number.MAX_SAFE_INTEGER).
|
||||
const channelId = String(rq.channel_id);
|
||||
|
||||
return {
|
||||
channel: rq.channel,
|
||||
channelId,
|
||||
timeoutMs: timeoutMinutes * 60 * 1000,
|
||||
pollIntervalMs: pollIntervalSeconds * 1000,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human-readable status string for the remote questions config.
|
||||
* Used by session_start notification and /gsd remote status.
|
||||
*/
|
||||
export function getRemoteConfigStatus(): string {
|
||||
const prefs = loadEffectiveGSDPreferences();
|
||||
const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
|
||||
|
||||
if (!rq || !rq.channel || !rq.channel_id) {
|
||||
return "Remote questions: not configured";
|
||||
}
|
||||
|
||||
const envVar = ENV_KEYS[rq.channel];
|
||||
if (!envVar) return `Remote questions: unknown channel type "${rq.channel}"`;
|
||||
|
||||
const token = process.env[envVar];
|
||||
if (!token) {
|
||||
return `Remote questions: ${envVar} not set — remote questions disabled`;
|
||||
}
|
||||
|
||||
return `Remote questions: ${rq.channel} (channel ${rq.channel_id}) configured`;
|
||||
}
|
||||
188
src/resources/extensions/remote-questions/discord-adapter.ts
Normal file
188
src/resources/extensions/remote-questions/discord-adapter.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Remote Questions — Discord adapter
|
||||
*
|
||||
* Uses Discord Bot HTTP API (no gateway/websocket):
|
||||
* - Send: POST /channels/{id}/messages with embed
|
||||
* - Poll: GET reactions + GET messages after the sent message
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
FormattedQuestion,
|
||||
PollReference,
|
||||
RemoteAnswer,
|
||||
SendResult,
|
||||
} from "./channels.js";
|
||||
import { formatForDiscord, parseDiscordResponse } from "./format.js";
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
|
||||
export class DiscordAdapter implements ChannelAdapter {
|
||||
readonly name = "discord";
|
||||
private botUserId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly channelId: string,
|
||||
) {}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
const res = await this.discordApi("GET", "/users/@me");
|
||||
if (!res.id) {
|
||||
throw new Error("Discord auth failed: invalid token");
|
||||
}
|
||||
this.botUserId = res.id as string;
|
||||
}
|
||||
|
||||
async sendQuestions(questions: FormattedQuestion[]): Promise<SendResult> {
|
||||
const { embeds, reactionEmojis } = formatForDiscord(questions);
|
||||
|
||||
const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, {
|
||||
content: "**GSD needs your input** — reply to this message or react with your choice",
|
||||
embeds,
|
||||
});
|
||||
|
||||
if (!res.id) {
|
||||
throw new Error(`Discord send failed: ${JSON.stringify(res)}`);
|
||||
}
|
||||
|
||||
const messageId = res.id as string;
|
||||
|
||||
// Add reaction emojis as templates (best-effort, don't block on failure)
|
||||
for (const emoji of reactionEmojis) {
|
||||
try {
|
||||
await this.discordApi(
|
||||
"PUT",
|
||||
`/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`,
|
||||
);
|
||||
} catch {
|
||||
// Non-critical — continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ref: {
|
||||
channelType: "discord",
|
||||
messageId,
|
||||
channelId: this.channelId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async pollResponse(ref: PollReference): Promise<RemoteAnswer | null> {
|
||||
return this.pollResponseWithQuestions(ref, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll with full question context for proper parsing.
|
||||
*/
|
||||
async pollResponseWithQuestions(
|
||||
ref: PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
): Promise<RemoteAnswer | null> {
|
||||
if (!this.botUserId) {
|
||||
const me = await this.discordApi("GET", "/users/@me");
|
||||
if (me.id) this.botUserId = me.id as string;
|
||||
}
|
||||
|
||||
// Strategy 1: Check reactions on the original message
|
||||
const reactionAnswer = await this.checkReactions(ref, questions);
|
||||
if (reactionAnswer) return reactionAnswer;
|
||||
|
||||
// Strategy 2: Check for text replies after the message
|
||||
const replyAnswer = await this.checkReplies(ref, questions);
|
||||
if (replyAnswer) return replyAnswer;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async checkReactions(
|
||||
ref: PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
): Promise<RemoteAnswer | null> {
|
||||
const numberEmojis = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"];
|
||||
const reactions: Array<{ emoji: string; count: number }> = [];
|
||||
|
||||
for (const emoji of numberEmojis) {
|
||||
try {
|
||||
const users = await this.discordApi(
|
||||
"GET",
|
||||
`/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`,
|
||||
);
|
||||
|
||||
if (Array.isArray(users)) {
|
||||
// Filter out bot's own reactions
|
||||
const humanUsers = users.filter(
|
||||
(u: { id: string }) => u.id !== this.botUserId,
|
||||
);
|
||||
if (humanUsers.length > 0) {
|
||||
reactions.push({ emoji, count: humanUsers.length });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Reaction not present or no access
|
||||
}
|
||||
}
|
||||
|
||||
if (reactions.length === 0) return null;
|
||||
|
||||
return parseDiscordResponse(reactions, null, questions);
|
||||
}
|
||||
|
||||
private async checkReplies(
|
||||
ref: PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
): Promise<RemoteAnswer | null> {
|
||||
const messages = await this.discordApi(
|
||||
"GET",
|
||||
`/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`,
|
||||
);
|
||||
|
||||
if (!Array.isArray(messages)) return null;
|
||||
|
||||
// Only accept replies that explicitly reference our message via Discord's reply feature
|
||||
const replies = messages.filter(
|
||||
(m: { author: { id: string }; message_reference?: { message_id: string }; content: string }) =>
|
||||
m.author.id !== this.botUserId &&
|
||||
m.message_reference?.message_id === ref.messageId,
|
||||
);
|
||||
|
||||
if (replies.length === 0) return null;
|
||||
|
||||
const firstReply = replies[0] as { content: string };
|
||||
return parseDiscordResponse([], firstReply.content, questions);
|
||||
}
|
||||
|
||||
// ─── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async discordApi(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = `${DISCORD_API}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
};
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
// For reaction PUT, 204 No Content is success
|
||||
if (response.status === 204) return {};
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Discord API HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
216
src/resources/extensions/remote-questions/format.ts
Normal file
216
src/resources/extensions/remote-questions/format.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Remote Questions — Payload formatting for Slack and Discord
|
||||
*
|
||||
* Converts Question[] to channel-specific payloads and parses replies
|
||||
* back into RemoteAnswer objects.
|
||||
*/
|
||||
|
||||
import type { FormattedQuestion, RemoteAnswer } from "./channels.js";
|
||||
|
||||
// ─── Slack Block Kit ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface SlackBlock {
|
||||
type: string;
|
||||
text?: { type: string; text: string };
|
||||
elements?: Array<{ type: string; text: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format questions as Slack Block Kit blocks for chat.postMessage.
|
||||
*/
|
||||
export function formatForSlack(questions: FormattedQuestion[]): SlackBlock[] {
|
||||
const blocks: SlackBlock[] = [
|
||||
{
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: "GSD needs your input" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const q of questions) {
|
||||
// Question header + text
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*${q.header}*\n${q.question}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Numbered options
|
||||
const optionLines = q.options.map(
|
||||
(opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`,
|
||||
);
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: optionLines.join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
// Instructions
|
||||
const instruction = q.allowMultiple
|
||||
? `Reply in this thread with numbers separated by comma (e.g. \`1,3\`) or type a custom answer.`
|
||||
: `Reply in this thread with the number of your choice (e.g. \`1\`) or type a custom answer.`;
|
||||
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: [{ type: "mrkdwn", text: instruction }],
|
||||
});
|
||||
|
||||
blocks.push({ type: "divider" });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ─── Discord Embed ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DiscordEmbed {
|
||||
title: string;
|
||||
description: string;
|
||||
color: number;
|
||||
fields: Array<{ name: string; value: string; inline?: boolean }>;
|
||||
footer?: { text: string };
|
||||
}
|
||||
|
||||
const NUMBER_EMOJIS = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"];
|
||||
|
||||
/**
|
||||
* Format questions as a Discord embed for channel message.
|
||||
*/
|
||||
export function formatForDiscord(questions: FormattedQuestion[]): { embeds: DiscordEmbed[]; reactionEmojis: string[] } {
|
||||
const allEmojis: string[] = [];
|
||||
const embeds: DiscordEmbed[] = [];
|
||||
|
||||
for (const q of questions) {
|
||||
const optionLines = q.options.map((opt, i) => {
|
||||
const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`;
|
||||
allEmojis.push(NUMBER_EMOJIS[i] ?? "");
|
||||
return `${emoji} **${opt.label}** — ${opt.description}`;
|
||||
});
|
||||
|
||||
const instruction = q.allowMultiple
|
||||
? "React with numbers or reply with comma-separated choices (e.g. `1,3`)"
|
||||
: "React with a number or reply with your choice";
|
||||
|
||||
embeds.push({
|
||||
title: `${q.header}`,
|
||||
description: q.question,
|
||||
color: 0x7c3aed, // Purple accent
|
||||
fields: [
|
||||
{ name: "Options", value: optionLines.join("\n") },
|
||||
],
|
||||
footer: { text: instruction },
|
||||
});
|
||||
}
|
||||
|
||||
return { embeds, reactionEmojis: allEmojis.filter(Boolean) };
|
||||
}
|
||||
|
||||
// ─── Reply Parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a Slack thread reply into a RemoteAnswer.
|
||||
* Supports: single number, comma-separated numbers, or free text.
|
||||
*/
|
||||
export function parseSlackReply(text: string, questions: FormattedQuestion[]): RemoteAnswer {
|
||||
const answers: RemoteAnswer["answers"] = {};
|
||||
const trimmed = text.trim();
|
||||
|
||||
// For single-question scenarios, map the reply directly
|
||||
if (questions.length === 1) {
|
||||
const q = questions[0];
|
||||
answers[q.id] = parseAnswerForQuestion(trimmed, q);
|
||||
return { answers };
|
||||
}
|
||||
|
||||
// Multi-question: try to split by lines or semicolons
|
||||
const parts = trimmed.includes(";")
|
||||
? trimmed.split(";").map((s) => s.trim())
|
||||
: trimmed.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const part = parts[i] ?? "";
|
||||
answers[q.id] = parseAnswerForQuestion(part, q);
|
||||
}
|
||||
|
||||
return { answers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Discord reaction or reply into a RemoteAnswer.
|
||||
*/
|
||||
export function parseDiscordResponse(
|
||||
reactions: Array<{ emoji: string; count: number }>,
|
||||
replyText: string | null,
|
||||
questions: FormattedQuestion[],
|
||||
): RemoteAnswer {
|
||||
// Prefer text reply if present
|
||||
if (replyText) {
|
||||
return parseSlackReply(replyText, questions);
|
||||
}
|
||||
|
||||
// Fall back to reactions
|
||||
const answers: RemoteAnswer["answers"] = {};
|
||||
|
||||
if (questions.length === 1) {
|
||||
const q = questions[0];
|
||||
const picked = reactions
|
||||
.filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
|
||||
.map((r) => {
|
||||
const idx = NUMBER_EMOJIS.indexOf(r.emoji);
|
||||
return q.options[idx]?.label;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (picked.length > 0) {
|
||||
answers[q.id] = { answers: picked };
|
||||
} else {
|
||||
answers[q.id] = { answers: [], user_note: "No clear response via reactions" };
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
// Multi-question with reactions: map first N emojis to first question
|
||||
for (const q of questions) {
|
||||
answers[q.id] = { answers: [], user_note: "Reaction-based multi-question not supported — use text reply" };
|
||||
}
|
||||
|
||||
return { answers };
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function parseAnswerForQuestion(
|
||||
text: string,
|
||||
q: FormattedQuestion,
|
||||
): { answers: string[]; user_note?: string } {
|
||||
if (!text) {
|
||||
return { answers: [], user_note: "No response provided" };
|
||||
}
|
||||
|
||||
// Check for comma-separated numbers: "1,3" or "1, 3"
|
||||
const numberPattern = /^[\d,\s]+$/;
|
||||
if (numberPattern.test(text)) {
|
||||
const nums = text
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n) && n >= 1 && n <= q.options.length);
|
||||
|
||||
if (nums.length > 0) {
|
||||
const selected = nums.map((n) => q.options[n - 1].label);
|
||||
return { answers: q.allowMultiple ? selected : [selected[0]] };
|
||||
}
|
||||
}
|
||||
|
||||
// Single number
|
||||
const singleNum = parseInt(text, 10);
|
||||
if (!isNaN(singleNum) && singleNum >= 1 && singleNum <= q.options.length) {
|
||||
return { answers: [q.options[singleNum - 1].label] };
|
||||
}
|
||||
|
||||
// Free text response
|
||||
return { answers: [], user_note: text };
|
||||
}
|
||||
213
src/resources/extensions/remote-questions/index.ts
Normal file
213
src/resources/extensions/remote-questions/index.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Remote Questions — Entry point
|
||||
*
|
||||
* Transparent routing: when ctx.hasUI is false and a remote channel is
|
||||
* configured, sends questions via Slack/Discord and polls for the response.
|
||||
*
|
||||
* The LLM keeps calling `ask_user_questions` as normal — this module
|
||||
* intercepts the non-interactive branch.
|
||||
*/
|
||||
|
||||
import type { FormattedQuestion, ChannelAdapter, RemoteAnswer } from "./channels.js";
|
||||
import { resolveRemoteConfig, type ResolvedConfig } from "./config.js";
|
||||
import { SlackAdapter } from "./slack-adapter.js";
|
||||
import { DiscordAdapter } from "./discord-adapter.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to send questions via a remote channel (Slack/Discord).
|
||||
* Returns a formatted ToolResult if successful, or null if no remote
|
||||
* channel is configured (caller falls back to the original error).
|
||||
*/
|
||||
export async function tryRemoteQuestions(
|
||||
questions: Question[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolResult | null> {
|
||||
const config = resolveRemoteConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const adapter = createAdapter(config);
|
||||
const formatted = questionsToFormatted(questions);
|
||||
|
||||
try {
|
||||
await adapter.validate();
|
||||
} catch (err) {
|
||||
return errorToolResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
let sendResult;
|
||||
try {
|
||||
sendResult = await adapter.sendQuestions(formatted);
|
||||
} catch (err) {
|
||||
return errorToolResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const threadInfo = sendResult.threadUrl
|
||||
? ` Thread: ${sendResult.threadUrl}`
|
||||
: "";
|
||||
|
||||
// Poll for response
|
||||
const answer = await pollWithTimeout(adapter, sendResult.ref, formatted, signal, config);
|
||||
|
||||
if (!answer) {
|
||||
// Timeout — return structured result so the LLM knows
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
timed_out: true,
|
||||
channel: config.channel,
|
||||
timeout_minutes: config.timeoutMs / 60000,
|
||||
thread_url: sendResult.threadUrl ?? null,
|
||||
message: `User did not respond within ${config.timeoutMs / 60000} minutes.${threadInfo}`,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
remote: true,
|
||||
channel: config.channel,
|
||||
timed_out: true,
|
||||
threadUrl: sendResult.threadUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Format the answer in the same structure as formatForLLM
|
||||
const formattedAnswer = formatRemoteAnswerForLLM(answer);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formattedAnswer }],
|
||||
details: {
|
||||
remote: true,
|
||||
channel: config.channel,
|
||||
timed_out: false,
|
||||
threadUrl: sendResult.threadUrl,
|
||||
questions,
|
||||
response: answer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createAdapter(config: ResolvedConfig): ChannelAdapter & {
|
||||
pollResponseWithQuestions?: (
|
||||
ref: import("./channels.js").PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
) => Promise<RemoteAnswer | null>;
|
||||
} {
|
||||
switch (config.channel) {
|
||||
case "slack":
|
||||
return new SlackAdapter(config.token, config.channelId);
|
||||
case "discord":
|
||||
return new DiscordAdapter(config.token, config.channelId);
|
||||
default:
|
||||
throw new Error(`Unknown channel type: ${config.channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWithTimeout(
|
||||
adapter: ReturnType<typeof createAdapter>,
|
||||
ref: import("./channels.js").PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
signal: AbortSignal | undefined,
|
||||
config: ResolvedConfig,
|
||||
): Promise<RemoteAnswer | null> {
|
||||
const deadline = Date.now() + config.timeoutMs;
|
||||
let retries = 0;
|
||||
const maxNetworkRetries = 1;
|
||||
|
||||
while (Date.now() < deadline && !signal?.aborted) {
|
||||
try {
|
||||
// Use the question-aware poll if available
|
||||
const answer = adapter.pollResponseWithQuestions
|
||||
? await adapter.pollResponseWithQuestions(ref, questions)
|
||||
: await adapter.pollResponse(ref);
|
||||
|
||||
if (answer) return answer;
|
||||
retries = 0; // Reset on successful poll
|
||||
} catch {
|
||||
retries++;
|
||||
if (retries > maxNetworkRetries) return null;
|
||||
}
|
||||
|
||||
await sleep(config.pollIntervalMs, signal);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onAbort = () => settle();
|
||||
const timer = setTimeout(() => settle(), ms);
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function questionsToFormatted(questions: Question[]): FormattedQuestion[] {
|
||||
return questions.map((q) => ({
|
||||
id: q.id,
|
||||
header: q.header,
|
||||
question: q.question,
|
||||
options: q.options,
|
||||
allowMultiple: q.allowMultiple ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RemoteAnswer into the same JSON structure as the local formatForLLM.
|
||||
* Structure: { answers: { [id]: { answers: string[] } } }
|
||||
*/
|
||||
function formatRemoteAnswerForLLM(answer: RemoteAnswer): string {
|
||||
const formatted: Record<string, { answers: string[] }> = {};
|
||||
for (const [id, data] of Object.entries(answer.answers)) {
|
||||
const list = [...data.answers];
|
||||
if (data.user_note) {
|
||||
list.push(`user_note: ${data.user_note}`);
|
||||
}
|
||||
formatted[id] = { answers: list };
|
||||
}
|
||||
return JSON.stringify({ answers: formatted });
|
||||
}
|
||||
|
||||
function errorToolResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { remote: true, error: true },
|
||||
};
|
||||
}
|
||||
461
src/resources/extensions/remote-questions/remote-command.ts
Normal file
461
src/resources/extensions/remote-questions/remote-command.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
/**
|
||||
* Remote Questions — /gsd remote command
|
||||
*
|
||||
* Interactive wizard for configuring Slack/Discord as a remote question channel.
|
||||
* Follows the patterns from wizard.ts and gsd/commands.ts.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveRemoteConfig, getRemoteConfigStatus } from "./config.js";
|
||||
import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "../gsd/preferences.js";
|
||||
|
||||
// ─── Public ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleRemote(
|
||||
subcommand: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
_pi: ExtensionAPI,
|
||||
): Promise<void> {
|
||||
const trimmed = subcommand.trim();
|
||||
|
||||
if (trimmed === "slack") {
|
||||
await handleSetupSlack(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "discord") {
|
||||
await handleSetupDiscord(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "status") {
|
||||
await handleRemoteStatus(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "disconnect") {
|
||||
await handleDisconnect(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: show current status and guide
|
||||
await handleRemoteMenu(ctx);
|
||||
}
|
||||
|
||||
// ─── Setup Slack ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSetupSlack(ctx: ExtensionCommandContext): Promise<void> {
|
||||
// Step 1: Collect token
|
||||
const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token");
|
||||
if (!token) {
|
||||
ctx.ui.notify("Slack setup cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token.startsWith("xoxb-")) {
|
||||
ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-. Setup cancelled.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Validate token
|
||||
ctx.ui.notify("Validating token...", "info");
|
||||
let botInfo: { ok: boolean; user?: string; team?: string; user_id?: string };
|
||||
try {
|
||||
const res = await fetch("https://slack.com/api/auth.test", {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
botInfo = (await res.json()) as typeof botInfo;
|
||||
} catch (err) {
|
||||
ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!botInfo.ok) {
|
||||
ctx.ui.notify("Token validation failed — check that the token is correct and the app is installed.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Token valid — bot: ${botInfo.user}, workspace: ${botInfo.team}`, "info");
|
||||
|
||||
// Step 3: Collect channel ID
|
||||
const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
|
||||
if (!channelId) {
|
||||
ctx.ui.notify("Slack setup cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Send test message
|
||||
ctx.ui.notify("Sending test message...", "info");
|
||||
try {
|
||||
const res = await fetch("https://slack.com/api/chat.postMessage", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: channelId,
|
||||
text: "GSD remote questions connected! This channel will receive questions during auto-mode.",
|
||||
}),
|
||||
});
|
||||
const result = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!result.ok) {
|
||||
ctx.ui.notify(`Could not send to channel: ${result.error}. Make sure the bot is invited to the channel.`, "error");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Save configuration
|
||||
saveTokenToAuth("slack_bot", token);
|
||||
process.env.SLACK_BOT_TOKEN = token;
|
||||
saveRemoteQuestionsConfig("slack", channelId);
|
||||
|
||||
ctx.ui.notify(`Slack connected — questions will arrive in channel ${channelId} during /gsd auto`, "info");
|
||||
}
|
||||
|
||||
// ─── Setup Discord ───────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise<void> {
|
||||
// Step 1: Collect token
|
||||
const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token");
|
||||
if (!token) {
|
||||
ctx.ui.notify("Discord setup cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Validate token
|
||||
ctx.ui.notify("Validating token...", "info");
|
||||
let botInfo: { id?: string; username?: string };
|
||||
try {
|
||||
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
ctx.ui.notify(`Token validation failed (HTTP ${res.status}) — check that the token is correct.`, "error");
|
||||
return;
|
||||
}
|
||||
botInfo = (await res.json()) as typeof botInfo;
|
||||
} catch (err) {
|
||||
ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Token valid — bot: ${botInfo.username}`, "info");
|
||||
|
||||
// Step 3: Collect channel ID
|
||||
const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)");
|
||||
if (!channelId) {
|
||||
ctx.ui.notify("Discord setup cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Send test message
|
||||
ctx.ui.notify("Sending test message...", "info");
|
||||
try {
|
||||
const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: "GSD remote questions connected! This channel will receive questions during auto-mode.",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
ctx.ui.notify(`Could not send to channel (HTTP ${res.status}): ${body}. Make sure the bot has access.`, "error");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Save configuration
|
||||
saveTokenToAuth("discord_bot", token);
|
||||
process.env.DISCORD_BOT_TOKEN = token;
|
||||
saveRemoteQuestionsConfig("discord", channelId);
|
||||
|
||||
ctx.ui.notify(`Discord connected — questions will arrive in channel ${channelId} during /gsd auto`, "info");
|
||||
}
|
||||
|
||||
// ─── Status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const config = resolveRemoteConfig();
|
||||
|
||||
if (!config) {
|
||||
ctx.ui.notify(getRemoteConfigStatus(), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
ctx.ui.notify("Checking connection...", "info");
|
||||
|
||||
try {
|
||||
if (config.channel === "slack") {
|
||||
const res = await fetch("https://slack.com/api/auth.test", {
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
});
|
||||
const data = (await res.json()) as { ok: boolean; user?: string; team?: string };
|
||||
if (data.ok) {
|
||||
ctx.ui.notify(
|
||||
`Remote questions: Slack connected\n Bot: ${data.user}\n Workspace: ${data.team}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify("Remote questions: Slack token invalid — run /gsd remote slack to reconfigure", "warning");
|
||||
}
|
||||
} else if (config.channel === "discord") {
|
||||
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: { Authorization: `Bot ${config.token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { username?: string };
|
||||
ctx.ui.notify(
|
||||
`Remote questions: Discord connected\n Bot: ${data.username}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify("Remote questions: Discord token invalid — run /gsd remote discord to reconfigure", "warning");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.ui.notify(`Remote questions: connection check failed — ${(err as Error).message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disconnect ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDisconnect(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const prefs = loadEffectiveGSDPreferences();
|
||||
if (!prefs?.preferences.remote_questions) {
|
||||
ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = prefs.preferences.remote_questions.channel;
|
||||
|
||||
// Remove from preferences file
|
||||
removeRemoteQuestionsConfig();
|
||||
|
||||
// Remove token from auth storage
|
||||
const provider = channel === "slack" ? "slack_bot" : "discord_bot";
|
||||
removeTokenFromAuth(provider);
|
||||
|
||||
// Clear env
|
||||
if (channel === "slack") delete process.env.SLACK_BOT_TOKEN;
|
||||
if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info");
|
||||
}
|
||||
|
||||
// ─── Menu ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const config = resolveRemoteConfig();
|
||||
|
||||
if (config) {
|
||||
ctx.ui.notify(
|
||||
`Remote questions: ${config.channel} (channel ${config.channelId})\n` +
|
||||
` Timeout: ${config.timeoutMs / 60000}m, poll interval: ${config.pollIntervalMs / 1000}s\n\n` +
|
||||
`Commands:\n` +
|
||||
` /gsd remote status — test connection\n` +
|
||||
` /gsd remote disconnect — remove configuration\n` +
|
||||
` /gsd remote slack — reconfigure with Slack\n` +
|
||||
` /gsd remote discord — reconfigure with Discord`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify(
|
||||
`No remote question channel configured.\n\n` +
|
||||
`Commands:\n` +
|
||||
` /gsd remote slack — set up Slack bot\n` +
|
||||
` /gsd remote discord — set up Discord bot\n` +
|
||||
` /gsd remote status — check configuration`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Input helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function promptMaskedInput(
|
||||
ctx: ExtensionCommandContext,
|
||||
label: string,
|
||||
hint: string,
|
||||
): Promise<string | null> {
|
||||
if (!ctx.hasUI) return null;
|
||||
|
||||
return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||
let value = "";
|
||||
|
||||
function render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("accent", ` ${label}`));
|
||||
lines.push(theme.fg("dim", ` ${hint}`));
|
||||
lines.push("");
|
||||
lines.push(` ${theme.fg("text", "*".repeat(Math.min(value.length, width - 4)))}`);
|
||||
lines.push("");
|
||||
lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
function handleInput(data: string): void {
|
||||
if (data === "\r" || data === "\n") {
|
||||
done(value.trim() || null);
|
||||
} else if (data === "\x1b" || data === "\x03") {
|
||||
done(null);
|
||||
} else if (data === "\x7f") {
|
||||
value = value.slice(0, -1);
|
||||
tui.invalidate();
|
||||
} else if (data.length === 1 && data >= " ") {
|
||||
value += data;
|
||||
tui.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
return { render, handleInput, invalidate: () => tui.invalidate() };
|
||||
});
|
||||
}
|
||||
|
||||
async function promptInput(
|
||||
ctx: ExtensionCommandContext,
|
||||
label: string,
|
||||
hint: string,
|
||||
): Promise<string | null> {
|
||||
if (!ctx.hasUI) return null;
|
||||
|
||||
return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||
let value = "";
|
||||
|
||||
function render(_width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("accent", ` ${label}`));
|
||||
lines.push(theme.fg("dim", ` ${hint}`));
|
||||
lines.push("");
|
||||
lines.push(` ${theme.fg("text", value)}`);
|
||||
lines.push("");
|
||||
lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
function handleInput(data: string): void {
|
||||
if (data === "\r" || data === "\n") {
|
||||
done(value.trim() || null);
|
||||
} else if (data === "\x1b" || data === "\x03") {
|
||||
done(null);
|
||||
} else if (data === "\x7f") {
|
||||
value = value.slice(0, -1);
|
||||
tui.invalidate();
|
||||
} else if (data.length === 1 && data >= " ") {
|
||||
value += data;
|
||||
tui.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
return { render, handleInput, invalidate: () => tui.invalidate() };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Persistence helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function getAuthFilePath(): string {
|
||||
return join(homedir(), ".gsd", "agent", "auth.json");
|
||||
}
|
||||
|
||||
function loadAuthJson(): Record<string, unknown> {
|
||||
const path = getAuthFilePath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuthJson(data: Record<string, unknown>): void {
|
||||
const path = getAuthFilePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function saveTokenToAuth(provider: string, token: string): void {
|
||||
const auth = loadAuthJson();
|
||||
auth[provider] = { type: "api_key", key: token };
|
||||
saveAuthJson(auth);
|
||||
}
|
||||
|
||||
function removeTokenFromAuth(provider: string): void {
|
||||
const auth = loadAuthJson();
|
||||
delete auth[provider];
|
||||
saveAuthJson(auth);
|
||||
}
|
||||
|
||||
function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void {
|
||||
const prefsPath = getGlobalGSDPreferencesPath();
|
||||
let content = "";
|
||||
|
||||
if (existsSync(prefsPath)) {
|
||||
content = readFileSync(prefsPath, "utf-8");
|
||||
}
|
||||
|
||||
// Check if frontmatter exists
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
|
||||
const remoteBlock = [
|
||||
`remote_questions:`,
|
||||
` channel: ${channel}`,
|
||||
` channel_id: "${channelId}"`,
|
||||
` timeout_minutes: 5`,
|
||||
` poll_interval_seconds: 5`,
|
||||
].join("\n");
|
||||
|
||||
if (fmMatch) {
|
||||
// Replace existing remote_questions or append to frontmatter
|
||||
let fm = fmMatch[1];
|
||||
const remoteRegex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/;
|
||||
if (remoteRegex.test(fm)) {
|
||||
fm = fm.replace(remoteRegex, remoteBlock);
|
||||
} else {
|
||||
fm = fm.trimEnd() + "\n" + remoteBlock;
|
||||
}
|
||||
content = `---\n${fm}\n---` + content.slice(fmMatch[0].length);
|
||||
} else {
|
||||
// Create new frontmatter
|
||||
content = `---\n${remoteBlock}\n---\n\n${content}`;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(prefsPath), { recursive: true });
|
||||
writeFileSync(prefsPath, content, "utf-8");
|
||||
}
|
||||
|
||||
function removeRemoteQuestionsConfig(): void {
|
||||
const prefsPath = getGlobalGSDPreferencesPath();
|
||||
if (!existsSync(prefsPath)) return;
|
||||
|
||||
let content = readFileSync(prefsPath, "utf-8");
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return;
|
||||
|
||||
let fm = fmMatch[1];
|
||||
// Remove remote_questions block from frontmatter
|
||||
fm = fm.replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim();
|
||||
|
||||
if (fm) {
|
||||
content = `---\n${fm}\n---` + content.slice(fmMatch[0].length);
|
||||
} else {
|
||||
// Frontmatter is now empty, remove it
|
||||
content = content.slice(fmMatch[0].length).replace(/^\n+/, "");
|
||||
}
|
||||
|
||||
writeFileSync(prefsPath, content, "utf-8");
|
||||
}
|
||||
176
src/resources/extensions/remote-questions/slack-adapter.ts
Normal file
176
src/resources/extensions/remote-questions/slack-adapter.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Remote Questions — Slack adapter
|
||||
*
|
||||
* Uses Slack Bot Token API (xoxb-*) for bidirectional messaging:
|
||||
* - Send: POST chat.postMessage with Block Kit
|
||||
* - Poll: GET conversations.replies to read thread responses
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
FormattedQuestion,
|
||||
PollReference,
|
||||
RemoteAnswer,
|
||||
SendResult,
|
||||
} from "./channels.js";
|
||||
import { formatForSlack, parseSlackReply } from "./format.js";
|
||||
|
||||
const SLACK_API = "https://slack.com/api";
|
||||
|
||||
export class SlackAdapter implements ChannelAdapter {
|
||||
readonly name = "slack";
|
||||
private botUserId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly channelId: string,
|
||||
) {}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
const res = await this.slackApi("auth.test", {});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`);
|
||||
}
|
||||
this.botUserId = res.user_id as string;
|
||||
}
|
||||
|
||||
async sendQuestions(questions: FormattedQuestion[]): Promise<SendResult> {
|
||||
const blocks = formatForSlack(questions);
|
||||
|
||||
const res = await this.slackApi("chat.postMessage", {
|
||||
channel: this.channelId,
|
||||
text: "GSD needs your input",
|
||||
blocks,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const ts = res.ts as string;
|
||||
const channel = res.channel as string;
|
||||
|
||||
return {
|
||||
ref: {
|
||||
channelType: "slack",
|
||||
messageId: ts,
|
||||
threadTs: ts,
|
||||
channelId: channel,
|
||||
},
|
||||
threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`,
|
||||
};
|
||||
}
|
||||
|
||||
async pollResponse(ref: PollReference): Promise<RemoteAnswer | null> {
|
||||
// Ensure we know our bot user ID
|
||||
if (!this.botUserId) {
|
||||
const authRes = await this.slackApi("auth.test", {});
|
||||
if (authRes.ok) this.botUserId = authRes.user_id as string;
|
||||
}
|
||||
|
||||
const res = await this.slackApi("conversations.replies", {
|
||||
channel: ref.channelId,
|
||||
ts: ref.threadTs!,
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Channel not found or no access — don't throw, just return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = (res.messages ?? []) as Array<{
|
||||
user: string;
|
||||
text: string;
|
||||
ts: string;
|
||||
}>;
|
||||
|
||||
// Filter out the bot's own messages — only user replies count
|
||||
const userReplies = messages.filter(
|
||||
(m) => m.ts !== ref.threadTs && m.user !== this.botUserId,
|
||||
);
|
||||
|
||||
if (userReplies.length === 0) return null;
|
||||
|
||||
// Use the first user reply
|
||||
const reply = userReplies[0];
|
||||
// We need the questions for parsing — store them on the ref isn't ideal,
|
||||
// so the caller will need to pass them. For now, return raw text wrapped.
|
||||
return { answers: { _raw: { answers: [reply.text] } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll with full question context for proper parsing.
|
||||
*/
|
||||
async pollResponseWithQuestions(
|
||||
ref: PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
): Promise<RemoteAnswer | null> {
|
||||
if (!this.botUserId) {
|
||||
const authRes = await this.slackApi("auth.test", {});
|
||||
if (authRes.ok) this.botUserId = authRes.user_id as string;
|
||||
}
|
||||
|
||||
const res = await this.slackApi("conversations.replies", {
|
||||
channel: ref.channelId,
|
||||
ts: ref.threadTs!,
|
||||
limit: "20",
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const messages = (res.messages ?? []) as Array<{
|
||||
user: string;
|
||||
text: string;
|
||||
ts: string;
|
||||
}>;
|
||||
|
||||
const userReplies = messages.filter(
|
||||
(m) => m.ts !== ref.threadTs && m.user !== this.botUserId,
|
||||
);
|
||||
|
||||
if (userReplies.length === 0) return null;
|
||||
|
||||
return parseSlackReply(userReplies[0].text, questions);
|
||||
}
|
||||
|
||||
// ─── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async slackApi(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = `${SLACK_API}/${method}`;
|
||||
|
||||
const isGet = method === "conversations.replies" || method === "auth.test";
|
||||
|
||||
let response: Response;
|
||||
if (isGet) {
|
||||
// GET params must be strings for URLSearchParams
|
||||
const stringParams: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
stringParams[k] = String(v);
|
||||
}
|
||||
const qs = new URLSearchParams(stringParams).toString();
|
||||
response = await fetch(`${url}?${qs}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${this.token}` },
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,8 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void {
|
|||
['brave_answers', 'BRAVE_ANSWERS_KEY'],
|
||||
['context7', 'CONTEXT7_API_KEY'],
|
||||
['jina', 'JINA_API_KEY'],
|
||||
['slack_bot', 'SLACK_BOT_TOKEN'],
|
||||
['discord_bot', 'DISCORD_BOT_TOKEN'],
|
||||
]
|
||||
for (const [provider, envVar] of providers) {
|
||||
if (!process.env[envVar]) {
|
||||
|
|
@ -133,6 +135,20 @@ const API_KEYS: ApiKeyConfig[] = [
|
|||
hint: '(clean page extraction)',
|
||||
description: 'High-quality web page content extraction',
|
||||
},
|
||||
{
|
||||
provider: 'slack_bot',
|
||||
envVar: 'SLACK_BOT_TOKEN',
|
||||
label: 'Slack Bot',
|
||||
hint: '(remote questions in auto-mode)',
|
||||
description: 'Bot token for remote questions via Slack',
|
||||
},
|
||||
{
|
||||
provider: 'discord_bot',
|
||||
envVar: 'DISCORD_BOT_TOKEN',
|
||||
label: 'Discord Bot',
|
||||
hint: '(remote questions in auto-mode)',
|
||||
description: 'Bot token for remote questions via Discord',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue