From a37ef56146af5bdc6c0998060fa6a309e73d3181 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 10:35:59 -0600 Subject: [PATCH] feat: harden remote questions flow --- .../extensions/ask-user-questions.ts | 4 +- src/resources/extensions/gsd/index.ts | 9 +- src/resources/extensions/gsd/preferences.ts | 6 +- .../gsd/tests/remote-questions.test.ts | 107 ++++ .../gsd/tests/remote-status.test.ts | 44 ++ .../extensions/remote-questions/config.ts | 62 +- .../remote-questions/discord-adapter.ts | 157 ++--- .../extensions/remote-questions/format.ts | 224 +++---- .../extensions/remote-questions/manager.ts | 171 ++++++ .../remote-questions/remote-command.ts | 549 +++++------------- .../remote-questions/slack-adapter.ts | 130 +---- .../extensions/remote-questions/status.ts | 23 + .../extensions/remote-questions/store.ts | 77 +++ .../extensions/remote-questions/types.ts | 75 +++ 14 files changed, 841 insertions(+), 797 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/remote-questions.test.ts create mode 100644 src/resources/extensions/gsd/tests/remote-status.test.ts create mode 100644 src/resources/extensions/remote-questions/manager.ts create mode 100644 src/resources/extensions/remote-questions/status.ts create mode 100644 src/resources/extensions/remote-questions/store.ts create mode 100644 src/resources/extensions/remote-questions/types.ts diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 71e09704b..0f9d803e7 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -120,7 +120,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { - const { tryRemoteQuestions } = await import("./remote-questions/send.js"); + const { tryRemoteQuestions } = await import("./remote-questions/manager.js"); const remoteResult = await tryRemoteQuestions(params.questions, signal); if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); @@ -168,7 +168,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string }) | undefined; + const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string; promptId?: string; status?: string }) | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 9d6376b5f..e0e491d17 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -85,10 +85,15 @@ export default function (pi: ExtensionAPI) { // Notify remote questions status if configured try { - const { getRemoteConfigStatus } = await import("../remote-questions/config.js"); + const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ + import("../remote-questions/config.js"), + import("../remote-questions/status.js"), + ]); const status = getRemoteConfigStatus(); + const latest = getLatestPromptSummary(); if (!status.includes("not configured")) { - ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); + const suffix = latest ? `\nLast remote prompt: ${latest.id} (${latest.status})` : ""; + ctx.ui.notify(`${status}${suffix}`, status.includes("disabled") ? "warning" : "info"); } } catch { // Remote questions module not available — ignore diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index a84fbceb7..30b567e75 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -33,9 +33,9 @@ export interface AutoSupervisorConfig { export interface RemoteQuestionsConfig { channel: "slack" | "discord"; - channel_id: string; - timeout_minutes?: number; // Default: 5 - poll_interval_seconds?: number; // Default: 5 + channel_id: string | number; + timeout_minutes?: number; // clamped to 1-30 + poll_interval_seconds?: number; // clamped to 2-30 } export interface GSDPreferences { diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts new file mode 100644 index 000000000..f409224ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; +import { resolveRemoteConfig } from "../../remote-questions/config.ts"; + +const originalEnv = { ...process.env }; + +test("parseSlackReply handles single-number single-question answers", () => { + const result = parseSlackReply("2", [{ + id: "choice", + header: "Choice", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }]); + + assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } }); +}); + +test("parseSlackReply handles multiline multi-question answers", () => { + const result = parseSlackReply("1\ncustom note", [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }, + { + id: "second", + header: "Second", + question: "Explain", + allowMultiple: false, + options: [ + { label: "Gamma", description: "G" }, + { label: "Delta", description: "D" }, + ], + }, + ]); + + assert.deepEqual(result, { + answers: { + first: { answers: ["Alpha"] }, + second: { answers: [], user_note: "custom note" }, + }, + }); +}); + +test("parseDiscordResponse handles single-question reactions", () => { + const result = parseDiscordResponse([{ emoji: "2️⃣", count: 1 }], null, [{ + id: "choice", + header: "Choice", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }]); + + assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } }); +}); + +test("parseDiscordResponse rejects multi-question reaction parsing", () => { + const result = parseDiscordResponse([{ emoji: "1️⃣", count: 1 }], null, [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [{ label: "Alpha", description: "A" }], + }, + { + id: "second", + header: "Second", + question: "Pick one", + allowMultiple: false, + options: [{ label: "Beta", description: "B" }], + }, + ]); + + assert.match(String(result.answers.first.user_note), /single-question prompts/i); + assert.match(String(result.answers.second.user_note), /single-question prompts/i); +}); + +test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { + process.env.SLACK_BOT_TOKEN = "token"; + const home = process.env.HOME!; + const fs = await import("node:fs"); + const path = await import("node:path"); + const prefsPath = path.join(home, ".gsd", "preferences.md"); + fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); + fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); + + const config = resolveRemoteConfig(); + assert.ok(config); + assert.equal(config?.timeoutMs, 30 * 60 * 1000); + assert.equal(config?.pollIntervalMs, 2 * 1000); + + process.env = { ...originalEnv }; +}); diff --git a/src/resources/extensions/gsd/tests/remote-status.test.ts b/src/resources/extensions/gsd/tests/remote-status.test.ts new file mode 100644 index 000000000..4ca3ff0ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-status.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createPromptRecord, writePromptRecord } from "../../remote-questions/store.ts"; +import { getLatestPromptSummary } from "../../remote-questions/status.ts"; + +test("getLatestPromptSummary returns latest stored prompt", async () => { + const home = process.env.HOME!; + const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}`); + mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); + process.env.HOME = tempHome; + + const recordA = createPromptRecord({ + id: "a-prompt", + channel: "slack", + createdAt: 1, + timeoutAt: 10, + pollIntervalMs: 5000, + questions: [], + }); + recordA.updatedAt = 1; + writePromptRecord(recordA); + + const recordB = createPromptRecord({ + id: "z-prompt", + channel: "discord", + createdAt: 2, + timeoutAt: 10, + pollIntervalMs: 5000, + questions: [], + }); + recordB.updatedAt = 2; + recordB.status = "answered"; + writePromptRecord(recordB); + + const latest = getLatestPromptSummary(); + assert.equal(latest?.id, "z-prompt"); + assert.equal(latest?.status, "answered"); + + process.env.HOME = home; + rmSync(tempHome, { recursive: true, force: true }); +}); diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts index 9c92f0fba..7fe7b7d2c 100644 --- a/src/resources/extensions/remote-questions/config.ts +++ b/src/resources/extensions/remote-questions/config.ts @@ -1,78 +1,66 @@ /** - * Remote Questions — Configuration resolution - * - * Reads remote_questions config from GSD preferences and verifies - * the corresponding token exists in process.env. + * Remote Questions — configuration resolution and validation */ import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js"; +import type { RemoteChannel } from "./types.js"; export interface ResolvedConfig { - channel: "slack" | "discord"; + channel: RemoteChannel; channelId: string; timeoutMs: number; pollIntervalMs: number; token: string; } -const ENV_KEYS: Record = { +const ENV_KEYS: Record = { slack: "SLACK_BOT_TOKEN", discord: "DISCORD_BOT_TOKEN", }; const DEFAULT_TIMEOUT_MINUTES = 5; const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const MIN_TIMEOUT_MINUTES = 1; +const MAX_TIMEOUT_MINUTES = 30; +const MIN_POLL_INTERVAL_SECONDS = 2; +const MAX_POLL_INTERVAL_SECONDS = 30; -/** - * 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; + if (rq.channel !== "slack" && rq.channel !== "discord") return null; - const envVar = ENV_KEYS[rq.channel]; - if (!envVar) return null; - - const token = process.env[envVar]; + const token = process.env[ENV_KEYS[rq.channel]]; 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); + const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES); + const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); return { channel: rq.channel, - channelId, + channelId: String(rq.channel_id), 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"; - } - + if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured"; + if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`; const envVar = ENV_KEYS[rq.channel]; - if (!envVar) return `Remote questions: unknown channel type "${rq.channel}"`; + if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`; - 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`; + const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES); + const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); + return `Remote questions: ${rq.channel} configured (timeout ${timeoutMinutes}m, poll ${pollIntervalSeconds}s)`; +} + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.max(min, Math.min(max, n)); } diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 97e145a00..e477af65a 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -1,24 +1,15 @@ /** * 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 type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; import { formatForDiscord, parseDiscordResponse } from "./format.js"; const DISCORD_API = "https://discord.com/api/v10"; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; export class DiscordAdapter implements ChannelAdapter { - readonly name = "discord"; + readonly name = "discord" as const; private botUserId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -30,161 +21,99 @@ export class DiscordAdapter implements ChannelAdapter { async validate(): Promise { 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; + if (!res.id) throw new Error("Discord auth failed: invalid token"); + this.botUserId = String(res.id); } - async sendQuestions(questions: FormattedQuestion[]): Promise { - const { embeds, reactionEmojis } = formatForDiscord(questions); - + async sendPrompt(prompt: RemotePrompt): Promise { + const { embeds, reactionEmojis } = formatForDiscord(prompt); const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, { - content: "**GSD needs your input** — reply to this message or react with your choice", + content: "**GSD needs your input** — reply to this message with your answer", embeds, }); - if (!res.id) { - throw new Error(`Discord send failed: ${JSON.stringify(res)}`); - } + 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 + const messageId = String(res.id); + if (prompt.questions.length === 1) { + for (const emoji of reactionEmojis) { + try { + await this.discordApi("PUT", `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`); + } catch { + // Best-effort only + } } } return { ref: { - channelType: "discord", + id: prompt.id, + channel: "discord", messageId, channelId: this.channelId, }, }; } - async pollResponse(ref: PollReference): Promise { - return this.pollResponseWithQuestions(ref, []); - } + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); - /** - * Poll with full question context for proper parsing. - */ - async pollResponseWithQuestions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - if (!this.botUserId) { - const me = await this.discordApi("GET", "/users/@me"); - if (me.id) this.botUserId = me.id as string; + if (prompt.questions.length === 1) { + const reactionAnswer = await this.checkReactions(prompt, ref); + if (reactionAnswer) return reactionAnswer; } - // 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; + return this.checkReplies(prompt, ref); } - private async checkReactions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - const numberEmojis = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { const reactions: Array<{ emoji: string; count: number }> = []; - - for (const emoji of numberEmojis) { + for (const emoji of NUMBER_EMOJIS) { try { - const users = await this.discordApi( - "GET", - `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`, - ); - + 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 }); - } + 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 + // ignore missing reaction } } if (reactions.length === 0) return null; - - return parseDiscordResponse(reactions, null, questions); + return parseDiscordResponse(reactions, null, prompt.questions); } - private async checkReplies( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - const messages = await this.discordApi( - "GET", - `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`, - ); - + private async checkReplies(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + 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?: string }; message_reference?: { message_id?: string }; content?: string }) => + m.author?.id && m.author.id !== this.botUserId && - m.message_reference?.message_id === ref.messageId, + m.message_reference?.message_id === ref.messageId && + m.content, ); if (replies.length === 0) return null; - - const firstReply = replies[0] as { content: string }; - return parseDiscordResponse([], firstReply.content, questions); + return parseDiscordResponse([], String(replies[0].content), prompt.questions); } - // ─── Internal ────────────────────────────────────────────────────────────── - - private async discordApi( - method: string, - path: string, - body?: unknown, - ): Promise> { - const url = `${DISCORD_API}${path}`; - - const headers: Record = { - Authorization: `Bot ${this.token}`, - }; - + private async discordApi(method: string, path: string, body?: unknown): Promise { + const headers: Record = { 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 + const response = await fetch(`${DISCORD_API}${path}`, init); 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; + return response.json(); } } diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index 348992c1d..dd01039b8 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -1,13 +1,8 @@ /** - * Remote Questions — Payload formatting for Slack and Discord - * - * Converts Question[] to channel-specific payloads and parses replies - * back into RemoteAnswer objects. + * Remote Questions — payload formatting and parsing helpers */ -import type { FormattedQuestion, RemoteAnswer } from "./channels.js"; - -// ─── Slack Block Kit ───────────────────────────────────────────────────────── +import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; export interface SlackBlock { type: string; @@ -15,57 +10,6 @@ export interface SlackBlock { 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; @@ -74,130 +18,130 @@ export interface DiscordEmbed { footer?: { text: string }; } -const NUMBER_EMOJIS = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; -/** - * Format questions as a Discord embed for channel message. - */ -export function formatForDiscord(questions: FormattedQuestion[]): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { - const allEmojis: string[] = []; - const embeds: DiscordEmbed[] = []; +export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "GSD needs your input" }, + }, + ]; - for (const q of questions) { + for (const q of prompt.questions) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` }, + }); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: q.options.map((opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`).join("\n"), + }, + }); + + blocks.push({ + type: "context", + elements: [{ + type: "mrkdwn", + text: q.allowMultiple + ? "Reply in thread with comma-separated numbers (`1,3`) or free text." + : "Reply in thread with a number (`1`) or free text.", + }], + }); + + blocks.push({ type: "divider" }); + } + + return blocks; +} + +export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { + const reactionEmojis: string[] = []; + const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => { + const supportsReactions = prompt.questions.length === 1; const optionLines = q.options.map((opt, i) => { const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`; - allEmojis.push(NUMBER_EMOJIS[i] ?? ""); + if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.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"; + 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`; - embeds.push({ - title: `${q.header}`, + return { + title: q.header, description: q.question, - color: 0x7c3aed, // Purple accent - fields: [ - { name: "Options", value: optionLines.join("\n") }, - ], - footer: { text: instruction }, - }); - } + color: 0x7c3aed, + fields: [{ name: "Options", value: optionLines.join("\n") }], + footer: { text: footerText }, + }; + }); - return { embeds, reactionEmojis: allEmojis.filter(Boolean) }; + return { embeds, reactionEmojis }; } -// ─── 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 { +export function parseSlackReply(text: string, questions: RemoteQuestion[]): 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); + answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]); return { answers }; } - // Multi-question: try to split by lines or semicolons const parts = trimmed.includes(";") - ? trimmed.split(";").map((s) => s.trim()) + ? trimmed.split(";").map((s) => s.trim()).filter(Boolean) : 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); + answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]); } 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[], + questions: RemoteQuestion[], ): RemoteAnswer { - // Prefer text reply if present - if (replyText) { - return parseSlackReply(replyText, questions); - } + 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" }; + if (questions.length !== 1) { + for (const q of questions) { + answers[q.id] = { answers: [], user_note: "Discord reactions are only supported for single-question prompts" }; } 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" }; - } + const q = questions[0]; + const picked = reactions + .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) + .map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label) + .filter(Boolean) as string[]; + + answers[q.id] = picked.length > 0 + ? { answers: q.allowMultiple ? picked : [picked[0]] } + : { answers: [], user_note: "No clear response via reactions" }; return { answers }; } -// ─── Internal helpers ──────────────────────────────────────────────────────── +function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } { + if (!text) return { answers: [], user_note: "No response provided" }; -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)) { + if (/^[\d,\s]+$/.test(text)) { const nums = text .split(",") .map((s) => parseInt(s.trim(), 10)) - .filter((n) => !isNaN(n) && n >= 1 && n <= q.options.length); + .filter((n) => !Number.isNaN(n) && n >= 1 && n <= q.options.length); if (nums.length > 0) { const selected = nums.map((n) => q.options[n - 1].label); @@ -205,12 +149,10 @@ function parseAnswerForQuestion( } } - // Single number - const singleNum = parseInt(text, 10); - if (!isNaN(singleNum) && singleNum >= 1 && singleNum <= q.options.length) { - return { answers: [q.options[singleNum - 1].label] }; + const single = parseInt(text, 10); + if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) { + return { answers: [q.options[single - 1].label] }; } - // Free text response return { answers: [], user_note: text }; } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts new file mode 100644 index 000000000..9baabbd58 --- /dev/null +++ b/src/resources/extensions/remote-questions/manager.ts @@ -0,0 +1,171 @@ +/** + * Remote Questions — orchestration manager + */ + +import { randomUUID } from "node:crypto"; +import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; +import { resolveRemoteConfig, type ResolvedConfig } from "./config.js"; +import { SlackAdapter } from "./slack-adapter.js"; +import { DiscordAdapter } from "./discord-adapter.js"; +import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js"; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; + details?: Record; +} + +interface QuestionInput { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple?: boolean; +} + +export async function tryRemoteQuestions( + questions: QuestionInput[], + signal?: AbortSignal, +): Promise { + const config = resolveRemoteConfig(); + if (!config) return null; + + const prompt = createPrompt(questions, config); + writePromptRecord(createPromptRecord(prompt)); + + const adapter = createAdapter(config); + try { + await adapter.validate(); + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel); + } + + let dispatch; + try { + dispatch = await adapter.sendPrompt(prompt); + markPromptDispatched(prompt.id, dispatch.ref); + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel); + } + + const answer = await pollUntilDone(adapter, prompt, dispatch.ref, signal); + if (!answer) { + markPromptStatus(prompt.id, signal?.aborted ? "cancelled" : "timed_out"); + return { + content: [{ + type: "text", + text: JSON.stringify({ + timed_out: true, + channel: config.channel, + prompt_id: prompt.id, + timeout_minutes: config.timeoutMs / 60000, + thread_url: dispatch.ref.threadUrl ?? null, + message: `User did not respond within ${config.timeoutMs / 60000} minutes.`, + }), + }], + details: { + remote: true, + channel: config.channel, + timed_out: true, + promptId: prompt.id, + threadUrl: dispatch.ref.threadUrl, + status: signal?.aborted ? "cancelled" : "timed_out", + }, + }; + } + + markPromptAnswered(prompt.id, answer); + return { + content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }], + details: { + remote: true, + channel: config.channel, + timed_out: false, + promptId: prompt.id, + threadUrl: dispatch.ref.threadUrl, + questions, + response: answer, + status: "answered", + }, + }; +} + +function createPrompt(questions: QuestionInput[], config: ResolvedConfig): RemotePrompt { + const createdAt = Date.now(); + return { + id: randomUUID(), + channel: config.channel, + createdAt, + timeoutAt: createdAt + config.timeoutMs, + pollIntervalMs: config.pollIntervalMs, + context: { source: "ask_user_questions" }, + questions: questions.map((q): RemoteQuestion => ({ + id: q.id, + header: q.header, + question: q.question, + options: q.options, + allowMultiple: q.allowMultiple ?? false, + })), + }; +} + +function createAdapter(config: ResolvedConfig): ChannelAdapter { + return config.channel === "slack" + ? new SlackAdapter(config.token, config.channelId) + : new DiscordAdapter(config.token, config.channelId); +} + +async function pollUntilDone( + adapter: ChannelAdapter, + prompt: RemotePrompt, + ref: import("./types.js").RemotePromptRef, + signal?: AbortSignal, +): Promise { + while (Date.now() < prompt.timeoutAt && !signal?.aborted) { + try { + const answer = await adapter.pollAnswer(prompt, ref); + updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); + if (answer) return answer; + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return null; + } + + await sleep(prompt.pollIntervalMs, signal); + } + + return null; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(); + const timer = setTimeout(() => { + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function formatForTool(answer: RemoteAnswer): Record { + const out: Record = {}; + for (const [id, data] of Object.entries(answer.answers)) { + const list = [...data.answers]; + if (data.user_note) list.push(`user_note: ${data.user_note}`); + out[id] = { answers: list }; + } + return out; +} + +function errorResult(message: string, channel: string): ToolResult { + return { + content: [{ type: "text", text: message }], + details: { remote: true, channel, error: true, status: "failed" }, + }; +} diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index 30fbd3702..be5796ff2 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -1,19 +1,15 @@ /** * 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 { AuthStorage } from "@mariozechner/pi-coding-agent"; import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; 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 ────────────────────────────────────────────────────────────────── +import { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js"; +import { getRemoteConfigStatus, resolveRemoteConfig } from "./config.js"; +import { getLatestPromptSummary } from "./status.js"; export async function handleRemote( subcommand: string, @@ -22,272 +18,186 @@ export async function handleRemote( ): Promise { const trimmed = subcommand.trim(); - if (trimmed === "slack") { - await handleSetupSlack(ctx); - return; - } + if (trimmed === "slack") return handleSetupSlack(ctx); + if (trimmed === "discord") return handleSetupDiscord(ctx); + if (trimmed === "status") return handleRemoteStatus(ctx); + if (trimmed === "disconnect") return handleDisconnect(ctx); - 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); + return handleRemoteMenu(ctx); } -// ─── Setup Slack ───────────────────────────────────────────────────────────── - async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { - // 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) return void ctx.ui.notify("Slack setup cancelled.", "info"); + if (!token.startsWith("xoxb-")) return void ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-.", "warning"); - 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; - } + const auth = await fetchJson("https://slack.com/api/auth.test", { headers: { Authorization: `Bearer ${token}` } }); + if (!auth?.ok) return void ctx.ui.notify("Token validation failed — check the token and app install.", "error"); - 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; - } + if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info"); - // 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; - } + const send = await fetchJson("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." }), + }); + if (!send?.ok) return void ctx.ui.notify(`Could not send to channel: ${send?.error ?? "unknown error"}`, "error"); - // Step 5: Save configuration - saveTokenToAuth("slack_bot", token); + saveProviderToken("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"); + ctx.ui.notify(`Slack connected — remote questions enabled for channel ${channelId}.`, "info"); } -// ─── Setup Discord ─────────────────────────────────────────────────────────── - async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { - // 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; - } + if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info"); - // 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; - } + const auth = await fetchJson("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bot ${token}` } }); + if (!auth?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error"); - 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; + if (!channelId) return void ctx.ui.notify("Discord setup cancelled.", "info"); + + const sendResponse = 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." }), + }); + if (!sendResponse.ok) { + const body = await sendResponse.text().catch(() => ""); + return void ctx.ui.notify(`Could not send to channel (HTTP ${sendResponse.status}): ${body}`, "error"); } - // 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); + saveProviderToken("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"); + ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info"); } -// ─── Status ────────────────────────────────────────────────────────────────── - async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise { + const status = getRemoteConfigStatus(); const config = resolveRemoteConfig(); - if (!config) { - ctx.ui.notify(getRemoteConfigStatus(), "info"); + ctx.ui.notify(status, status.includes("disabled") ? "warning" : "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"); + const latestPrompt = getLatestPromptSummary(); + const lines = [status]; + if (latestPrompt) { + lines.push(`Last prompt: ${latestPrompt.id}`); + lines.push(` status: ${latestPrompt.status}`); + if (latestPrompt.updatedAt) lines.push(` updated: ${new Date(latestPrompt.updatedAt).toLocaleString()}`); } -} -// ─── Disconnect ────────────────────────────────────────────────────────────── + ctx.ui.notify(lines.join("\n"), "info"); +} async function handleDisconnect(ctx: ExtensionCommandContext): Promise { 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; + if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); - 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 + removeProviderToken(channel === "slack" ? "slack_bot" : "discord_bot"); 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 { const config = resolveRemoteConfig(); + const latestPrompt = getLatestPromptSummary(); + const lines = config + ? [ + `Remote questions: ${config.channel} configured`, + ` Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + latestPrompt ? ` Last prompt: ${latestPrompt.id} (${latestPrompt.status})` : " No remote prompts recorded yet", + "", + "Commands:", + " /gsd remote status", + " /gsd remote disconnect", + " /gsd remote slack", + " /gsd remote discord", + ] + : [ + "No remote question channel configured.", + "", + "Commands:", + " /gsd remote slack", + " /gsd remote discord", + " /gsd remote status", + ]; - 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", - ); + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + try { + const response = await fetch(url, init); + return await response.json(); + } catch { + return null; } } -// ─── Input helpers ─────────────────────────────────────────────────────────── +function getAuthStorage(): AuthStorage { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + mkdirSync(dirname(authPath), { recursive: true }); + return AuthStorage.create(authPath); +} + +function saveProviderToken(provider: string, token: string): void { + const auth = getAuthStorage(); + auth.set(provider, { type: "api_key", key: token }); +} + +function removeProviderToken(provider: string): void { + const auth = getAuthStorage(); + auth.set(provider, { type: "api_key", key: "" }); +} + +function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { + const prefsPath = getGlobalGSDPreferencesPath(); + const block = [ + "remote_questions:", + ` channel: ${channel}`, + ` channel_id: \"${channelId}\"`, + " timeout_minutes: 5", + " poll_interval_seconds: 5", + ].join("\n"); + + const content = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : ""; + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + let next = content; + + if (fmMatch) { + let frontmatter = fmMatch[1]; + const regex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; + frontmatter = regex.test(frontmatter) ? frontmatter.replace(regex, block) : `${frontmatter.trimEnd()}\n${block}`; + next = `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}`; + } else { + next = `---\n${block}\n---\n\n${content}`; + } + + mkdirSync(dirname(prefsPath), { recursive: true }); + writeFileSync(prefsPath, next, "utf-8"); +} + +function removeRemoteQuestionsConfig(): void { + const prefsPath = getGlobalGSDPreferencesPath(); + if (!existsSync(prefsPath)) return; + const content = readFileSync(prefsPath, "utf-8"); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return; + const frontmatter = fmMatch[1].replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); + const next = frontmatter ? `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}` : content.slice(fmMatch[0].length).replace(/^\n+/, ""); + writeFileSync(prefsPath, next, "utf-8"); +} function maskEditorLine(line: string): string { let output = ""; @@ -304,20 +214,14 @@ function maskEditorLine(line: string): string { i += ansiMatch[0].length; continue; } - const ch = line[i] as string; - output += ch === " " ? " " : "*"; + output += line[i] === " " ? " " : "*"; i += 1; } return output; } -async function promptMaskedInput( - ctx: ExtensionCommandContext, - label: string, - hint: string, -): Promise { +async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { @@ -331,56 +235,34 @@ async function promptMaskedInput( }, }; const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { - const value = editor.getText().trim(); - done(value.length > 0 ? value : null); - return; - } - if (matchesKey(data, Key.escape)) { - done(null); - return; - } - editor.handleInput(data); - refresh(); - } - - function render(width: number): string[] { + const refresh = () => { cachedLines = undefined; tui.requestRender(); }; + const handleInput = (data: string) => { + if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null); + if (matchesKey(data, Key.escape)) return done(null); + editor.handleInput(data); refresh(); + }; + const render = (width: number) => { if (cachedLines) return cachedLines; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", "─".repeat(width))); add(theme.fg("accent", theme.bold(` ${label}`))); add(theme.fg("muted", ` ${hint}`)); lines.push(""); add(theme.fg("muted", " Enter value:")); - for (const line of editor.render(width - 2)) { - add(theme.fg("text", maskEditorLine(line))); - } + for (const line of editor.render(width - 2)) add(theme.fg("text", maskEditorLine(line))); lines.push(""); - add(theme.fg("dim", ` enter to confirm | esc to cancel`)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("dim", " enter to confirm | esc to cancel")); + add(theme.fg("accent", "─".repeat(width))); cachedLines = lines; return lines; - } - + }; return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } -async function promptInput( - ctx: ExtensionCommandContext, - label: string, - hint: string, -): Promise { +async function promptInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { @@ -394,139 +276,28 @@ async function promptInput( }, }; const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { - const value = editor.getText().trim(); - done(value.length > 0 ? value : null); - return; - } - if (matchesKey(data, Key.escape)) { - done(null); - return; - } - editor.handleInput(data); - refresh(); - } - - function render(width: number): string[] { + const refresh = () => { cachedLines = undefined; tui.requestRender(); }; + const handleInput = (data: string) => { + if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null); + if (matchesKey(data, Key.escape)) return done(null); + editor.handleInput(data); refresh(); + }; + const render = (width: number) => { if (cachedLines) return cachedLines; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", "─".repeat(width))); add(theme.fg("accent", theme.bold(` ${label}`))); add(theme.fg("muted", ` ${hint}`)); lines.push(""); add(theme.fg("muted", " Enter value:")); - for (const line of editor.render(width - 2)) { - add(theme.fg("text", line)); - } + for (const line of editor.render(width - 2)) add(theme.fg("text", line)); lines.push(""); - add(theme.fg("dim", ` enter to confirm | esc to cancel`)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("dim", " enter to confirm | esc to cancel")); + add(theme.fg("accent", "─".repeat(width))); cachedLines = lines; return lines; - } - + }; return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } - -// ─── Persistence helpers ───────────────────────────────────────────────────── - -function getAuthFilePath(): string { - return join(homedir(), ".gsd", "agent", "auth.json"); -} - -function loadAuthJson(): Record { - const path = getAuthFilePath(); - if (!existsSync(path)) return {}; - try { - return JSON.parse(readFileSync(path, "utf-8")) as Record; - } catch { - return {}; - } -} - -function saveAuthJson(data: Record): 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"); -} diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 1f3beff17..6bc69b036 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -1,24 +1,14 @@ /** * 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 type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; import { formatForSlack, parseSlackReply } from "./format.js"; const SLACK_API = "https://slack.com/api"; export class SlackAdapter implements ChannelAdapter { - readonly name = "slack"; + readonly name = "slack" as const; private botUserId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -30,88 +20,35 @@ export class SlackAdapter implements ChannelAdapter { async validate(): Promise { 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; + if (!res.ok) throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); + this.botUserId = String(res.user_id ?? ""); } - async sendQuestions(questions: FormattedQuestion[]): Promise { - const blocks = formatForSlack(questions); - + async sendPrompt(prompt: RemotePrompt): Promise { const res = await this.slackApi("chat.postMessage", { channel: this.channelId, text: "GSD needs your input", - blocks, + blocks: formatForSlack(prompt), }); - if (!res.ok) { - throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); - } - - const ts = res.ts as string; - const channel = res.channel as string; + if (!res.ok) throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); + const ts = String(res.ts); + const channel = String(res.channel); return { ref: { - channelType: "slack", + id: prompt.id, + channel: "slack", messageId: ts, threadTs: ts, channelId: channel, + threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, }, - threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, }; } - async pollResponse(ref: PollReference): Promise { - // 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 { - if (!this.botUserId) { - const authRes = await this.slackApi("auth.test", {}); - if (authRes.ok) this.botUserId = authRes.user_id as string; - } + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); const res = await this.slackApi("conversations.replies", { channel: ref.channelId, @@ -121,43 +58,21 @@ export class SlackAdapter implements ChannelAdapter { 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, - ); - + const messages = (res.messages ?? []) as Array<{ user?: string; text?: string; ts: string }>; + const userReplies = messages.filter((m) => m.ts !== ref.threadTs && m.user && m.user !== this.botUserId && m.text); if (userReplies.length === 0) return null; - return parseSlackReply(userReplies[0].text, questions); + return parseSlackReply(String(userReplies[0].text), prompt.questions); } - // ─── Internal ────────────────────────────────────────────────────────────── - - private async slackApi( - method: string, - params: Record, - ): Promise> { + private async slackApi(method: string, params: Record): Promise> { 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 = {}; - 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}` }, - }); + const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString(); + response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` } }); } else { response = await fetch(url, { method: "POST", @@ -169,10 +84,7 @@ export class SlackAdapter implements ChannelAdapter { }); } - if (!response.ok) { - throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); - } - + if (!response.ok) throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); return (await response.json()) as Record; } } diff --git a/src/resources/extensions/remote-questions/status.ts b/src/resources/extensions/remote-questions/status.ts new file mode 100644 index 000000000..e322aeaf8 --- /dev/null +++ b/src/resources/extensions/remote-questions/status.ts @@ -0,0 +1,23 @@ +/** + * Remote Questions — status helpers + */ + +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { readPromptRecord } from "./store.js"; + +export interface LatestPromptSummary { + id: string; + status: string; + updatedAt: number; +} + +export function getLatestPromptSummary(): LatestPromptSummary | null { + const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions"); + if (!existsSync(runtimeDir)) return null; + const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")).sort().reverse(); + if (files.length === 0) return null; + const record = readPromptRecord(files[0].replace(/\.json$/, "")); + return record ? { id: record.id, status: record.status, updatedAt: record.updatedAt } : null; +} diff --git a/src/resources/extensions/remote-questions/store.ts b/src/resources/extensions/remote-questions/store.ts new file mode 100644 index 000000000..226ac8996 --- /dev/null +++ b/src/resources/extensions/remote-questions/store.ts @@ -0,0 +1,77 @@ +/** + * Remote Questions — durable prompt store + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js"; + +function runtimeDir(): string { + return join(homedir(), ".gsd", "runtime", "remote-questions"); +} + +function recordPath(id: string): string { + return join(runtimeDir(), `${id}.json`); +} + +export function createPromptRecord(prompt: RemotePrompt): RemotePromptRecord { + return { + version: 1, + id: prompt.id, + createdAt: prompt.createdAt, + updatedAt: Date.now(), + status: "pending", + channel: prompt.channel, + timeoutAt: prompt.timeoutAt, + pollIntervalMs: prompt.pollIntervalMs, + questions: prompt.questions, + context: prompt.context, + }; +} + +export function writePromptRecord(record: RemotePromptRecord): void { + mkdirSync(runtimeDir(), { recursive: true }); + writeFileSync(recordPath(record.id), JSON.stringify(record, null, 2) + "\n", "utf-8"); +} + +export function readPromptRecord(id: string): RemotePromptRecord | null { + const path = recordPath(id); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as RemotePromptRecord; + } catch { + return null; + } +} + +export function updatePromptRecord( + id: string, + updates: Partial, +): RemotePromptRecord | null { + const current = readPromptRecord(id); + if (!current) return null; + const next: RemotePromptRecord = { + ...current, + ...updates, + updatedAt: Date.now(), + }; + writePromptRecord(next); + return next; +} + +export function markPromptDispatched(id: string, ref: RemotePromptRef): RemotePromptRecord | null { + return updatePromptRecord(id, { ref, status: "pending" }); +} + +export function markPromptAnswered(id: string, response: RemoteAnswer): RemotePromptRecord | null { + return updatePromptRecord(id, { response, status: "answered", lastPollAt: Date.now() }); +} + +export function markPromptStatus(id: string, status: RemotePromptStatus, lastError?: string): RemotePromptRecord | null { + return updatePromptRecord(id, { + status, + lastPollAt: Date.now(), + ...(lastError ? { lastError } : {}), + }); +} diff --git a/src/resources/extensions/remote-questions/types.ts b/src/resources/extensions/remote-questions/types.ts new file mode 100644 index 000000000..b1237fdf7 --- /dev/null +++ b/src/resources/extensions/remote-questions/types.ts @@ -0,0 +1,75 @@ +/** + * Remote Questions — shared types + */ + +export type RemoteChannel = "slack" | "discord"; + +export interface RemoteQuestionOption { + label: string; + description: string; +} + +export interface RemoteQuestion { + id: string; + header: string; + question: string; + options: RemoteQuestionOption[]; + allowMultiple: boolean; +} + +export interface RemotePrompt { + id: string; + channel: RemoteChannel; + createdAt: number; + timeoutAt: number; + pollIntervalMs: number; + questions: RemoteQuestion[]; + context?: { + source: string; + }; +} + +export interface RemotePromptRef { + id: string; + channel: RemoteChannel; + messageId: string; + channelId: string; + threadTs?: string; + threadUrl?: string; +} + +export interface RemoteAnswer { + answers: Record; +} + +export type RemotePromptStatus = "pending" | "answered" | "timed_out" | "failed" | "cancelled"; + +export interface RemotePromptRecord { + version: 1; + id: string; + createdAt: number; + updatedAt: number; + status: RemotePromptStatus; + channel: RemoteChannel; + timeoutAt: number; + pollIntervalMs: number; + questions: RemoteQuestion[]; + ref?: RemotePromptRef; + response?: RemoteAnswer; + lastPollAt?: number; + lastError?: string; + context?: { + source: string; + }; +} + +export interface RemoteDispatchResult { + ref: RemotePromptRef; +} + +export interface ChannelAdapter { + readonly name: RemoteChannel; + validate(): Promise; + sendPrompt(prompt: RemotePrompt): Promise; + pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise; +}