diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 4446e676c..23f97decb 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -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; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 65ac405a2..d3f8f30d3 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 .`, + `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 , or /gsd remote [slack|discord|status|disconnect].`, "warning", ); }, diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 018843df1..9d6376b5f 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -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 ──────────────────────── diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 222fa3d03..a84fbceb7 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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, }; } diff --git a/src/resources/extensions/remote-questions/channels.ts b/src/resources/extensions/remote-questions/channels.ts new file mode 100644 index 000000000..7360c00a3 --- /dev/null +++ b/src/resources/extensions/remote-questions/channels.ts @@ -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; + pollResponse(ref: PollReference): Promise; + validate(): Promise; +} + +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; +} diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts new file mode 100644 index 000000000..9c92f0fba --- /dev/null +++ b/src/resources/extensions/remote-questions/config.ts @@ -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 = { + 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`; +} diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts new file mode 100644 index 000000000..df54ef6bd --- /dev/null +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -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 { + 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 { + 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 { + return this.pollResponseWithQuestions(ref, []); + } + + /** + * 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; + } + + // 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 { + 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 { + 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> { + const url = `${DISCORD_API}${path}`; + + 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 + 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; + } +} diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts new file mode 100644 index 000000000..348992c1d --- /dev/null +++ b/src/resources/extensions/remote-questions/format.ts @@ -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 }; +} diff --git a/src/resources/extensions/remote-questions/index.ts b/src/resources/extensions/remote-questions/index.ts new file mode 100644 index 000000000..90d6e293b --- /dev/null +++ b/src/resources/extensions/remote-questions/index.ts @@ -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; +} + +// ─── 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 { + 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; +} { + 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, + ref: import("./channels.js").PollReference, + questions: FormattedQuestion[], + signal: AbortSignal | undefined, + config: ResolvedConfig, +): Promise { + 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 { + 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 = {}; + 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 }, + }; +} diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts new file mode 100644 index 000000000..a43b7bfca --- /dev/null +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + 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 { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((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 { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((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 { + 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 new file mode 100644 index 000000000..8b48b328e --- /dev/null +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -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 { + 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 { + 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 { + // 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; + } + + 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, + ): 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}` }, + }); + } 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; + } +} diff --git a/src/wizard.ts b/src/wizard.ts index 3706f5cae..fef191e5d 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -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', + }, ] /**