Merge pull request #42 from FacuVCanale/feat/remote-user-questions
Merging — minor cleanup items will be handled in a follow-up commit. Thanks @FacuVCanale for the solid work and patience through the review rounds.
This commit is contained in:
commit
28a30c2cc3
18 changed files with 1760 additions and 10 deletions
|
|
@ -21,12 +21,27 @@ import {
|
|||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AskUserQuestionsDetails {
|
||||
interface LocalResultDetails {
|
||||
remote?: false;
|
||||
questions: Question[];
|
||||
response: RoundResult | null;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
interface RemoteResultDetails {
|
||||
remote: true;
|
||||
channel: string;
|
||||
timed_out: boolean;
|
||||
promptId?: string;
|
||||
threadUrl?: string;
|
||||
status?: string;
|
||||
questions?: Question[];
|
||||
response?: import("./remote-questions/types.js").RemoteAnswer;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
type AskUserQuestionsDetails = LocalResultDetails | RemoteResultDetails;
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const OptionSchema = Type.Object({
|
||||
|
|
@ -104,7 +119,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 +135,9 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -131,13 +149,13 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
if (!hasAnswers) {
|
||||
return {
|
||||
content: [{ type: "text", text: "ask_user_questions was cancelled before receiving a response" }],
|
||||
details: { questions: params.questions, response: null, cancelled: true } as AskUserQuestionsDetails,
|
||||
details: { questions: params.questions, response: null, cancelled: true } satisfies LocalResultDetails,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formatForLLM(result) }],
|
||||
details: { questions: params.questions, response: result, cancelled: false } as AskUserQuestionsDetails,
|
||||
details: { questions: params.questions, response: result, cancelled: false } satisfies LocalResultDetails,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -171,13 +189,44 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
|
||||
// Remote channel result (discriminated on details.remote === true)
|
||||
if (details.remote) {
|
||||
if (details.timed_out) {
|
||||
return new Text(
|
||||
`${theme.fg("warning", `${details.channel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const questions = (details.questions ?? []) as Question[];
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.fg("dim", details.channel));
|
||||
if (details.response) {
|
||||
for (const q of questions) {
|
||||
const answer = details.response.answers[q.id];
|
||||
if (!answer) {
|
||||
lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`);
|
||||
continue;
|
||||
}
|
||||
const answerText = answer.answers.length > 0 ? answer.answers.join(", ") : "(custom)";
|
||||
let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`;
|
||||
if (answer.user_note) {
|
||||
line += ` ${theme.fg("muted", `[note: ${answer.user_note}]`)}`;
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
if (details.cancelled || !details.response) {
|
||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const q of details.questions) {
|
||||
const answer = details.response.answers[q.id];
|
||||
const answer = (details.response as RoundResult).answers[q.id];
|
||||
if (!answer) {
|
||||
lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "./doctor.js";
|
||||
import { loadPrompt } from "./prompt-loader.js";
|
||||
import { handleMigrate } from "./migrate/command.js";
|
||||
import { handleRemote } from "../remote-questions/remote-command.js";
|
||||
|
||||
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
|
||||
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
|
||||
|
|
@ -52,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
|
|||
|
||||
export function registerGSDCommand(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("gsd", {
|
||||
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate",
|
||||
description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate|remote",
|
||||
|
||||
getArgumentCompletions: (prefix: string) => {
|
||||
const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
|
||||
const subcommands = ["next", "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"];
|
||||
|
|
@ -148,6 +156,11 @@ 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 === "") {
|
||||
// Bare /gsd defaults to step mode
|
||||
await startAuto(ctx, pi, process.cwd(), false, { step: true });
|
||||
|
|
@ -155,7 +168,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate <path>.`,
|
||||
`Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
|
||||
"warning",
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -102,7 +102,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";
|
||||
|
|
@ -112,6 +112,22 @@ 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 }, { 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")) {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
// ── Ctrl+Alt+G shortcut — GSD dashboard overlay ────────────────────────
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ export interface AutoSupervisorConfig {
|
|||
hard_timeout_minutes?: number;
|
||||
}
|
||||
|
||||
export interface RemoteQuestionsConfig {
|
||||
channel: "slack" | "discord";
|
||||
channel_id: string | number;
|
||||
timeout_minutes?: number; // clamped to 1-30
|
||||
poll_interval_seconds?: number; // clamped to 2-30
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
155
src/resources/extensions/gsd/tests/remote-questions.test.ts
Normal file
155
src/resources/extensions/gsd/tests/remote-questions.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts";
|
||||
import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts";
|
||||
import { sanitizeError } from "../../remote-questions/manager.ts";
|
||||
|
||||
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("parseSlackReply truncates user_note longer than 500 chars", () => {
|
||||
const longText = "x".repeat(600);
|
||||
const result = parseSlackReply(longText, [{
|
||||
id: "q1",
|
||||
header: "Q1",
|
||||
question: "Pick",
|
||||
allowMultiple: false,
|
||||
options: [{ label: "A", description: "a" }],
|
||||
}]);
|
||||
|
||||
const note = result.answers.q1.user_note!;
|
||||
assert.ok(note.length <= 502, `note should be truncated, got ${note.length} chars`);
|
||||
assert.ok(note.endsWith("…"), "truncated note should end with ellipsis");
|
||||
});
|
||||
|
||||
test("isValidChannelId rejects invalid Slack channel IDs", () => {
|
||||
// Too short
|
||||
assert.equal(isValidChannelId("slack", "C123"), false);
|
||||
// Contains invalid chars (URL injection)
|
||||
assert.equal(isValidChannelId("slack", "https://evil.com"), false);
|
||||
// Lowercase
|
||||
assert.equal(isValidChannelId("slack", "c12345678"), false);
|
||||
// Too long
|
||||
assert.equal(isValidChannelId("slack", "C1234567890AB"), false);
|
||||
// Valid: 9-12 uppercase alphanumeric
|
||||
assert.equal(isValidChannelId("slack", "C12345678"), true);
|
||||
assert.equal(isValidChannelId("slack", "C12345678AB"), true);
|
||||
assert.equal(isValidChannelId("slack", "C1234567890A"), true);
|
||||
});
|
||||
|
||||
test("isValidChannelId rejects invalid Discord channel IDs", () => {
|
||||
// Too short
|
||||
assert.equal(isValidChannelId("discord", "12345"), false);
|
||||
// Contains letters (not a snowflake)
|
||||
assert.equal(isValidChannelId("discord", "abc12345678901234"), false);
|
||||
// URL injection
|
||||
assert.equal(isValidChannelId("discord", "https://evil.com"), false);
|
||||
// Too long (21 digits)
|
||||
assert.equal(isValidChannelId("discord", "123456789012345678901"), false);
|
||||
// Valid: 17-20 digit snowflake
|
||||
assert.equal(isValidChannelId("discord", "12345678901234567"), true);
|
||||
assert.equal(isValidChannelId("discord", "11234567890123456789"), true);
|
||||
});
|
||||
|
||||
test("sanitizeError strips Slack token patterns from error messages", () => {
|
||||
assert.equal(
|
||||
sanitizeError("Auth failed: xoxb-1234-5678-abcdef"),
|
||||
"Auth failed: [REDACTED]",
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeError("Bad token xoxp-abc-def-ghi in request"),
|
||||
"Bad token [REDACTED] in request",
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeError strips long opaque secrets", () => {
|
||||
const fakeDiscordToken = "MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.G1x2y3.abcdefghijklmnop";
|
||||
assert.ok(!sanitizeError(`Token: ${fakeDiscordToken}`).includes(fakeDiscordToken));
|
||||
});
|
||||
|
||||
test("sanitizeError preserves short safe messages", () => {
|
||||
assert.equal(sanitizeError("HTTP 401: Unauthorized"), "HTTP 401: Unauthorized");
|
||||
assert.equal(sanitizeError("Connection refused"), "Connection refused");
|
||||
});
|
||||
|
||||
99
src/resources/extensions/gsd/tests/remote-status.test.ts
Normal file
99
src/resources/extensions/gsd/tests/remote-status.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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";
|
||||
|
||||
function withTempHome(fn: (tempHome: string) => void | Promise<void>) {
|
||||
return async () => {
|
||||
const savedHome = process.env.HOME;
|
||||
const savedUserProfile = process.env.USERPROFILE;
|
||||
const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true });
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
try {
|
||||
await fn(tempHome);
|
||||
} finally {
|
||||
process.env.HOME = savedHome;
|
||||
process.env.USERPROFILE = savedUserProfile;
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test("getLatestPromptSummary returns latest stored prompt", withTempHome(() => {
|
||||
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");
|
||||
}));
|
||||
|
||||
test("getLatestPromptSummary sorts by updatedAt, not filename", withTempHome(() => {
|
||||
// Record with alphabetically-LAST id but OLDEST timestamp
|
||||
const old = createPromptRecord({
|
||||
id: "zzz-oldest",
|
||||
channel: "slack",
|
||||
createdAt: 1000,
|
||||
timeoutAt: 9999,
|
||||
pollIntervalMs: 5000,
|
||||
questions: [],
|
||||
});
|
||||
old.updatedAt = 1000;
|
||||
writePromptRecord(old);
|
||||
|
||||
// Record with alphabetically-FIRST id but NEWEST timestamp
|
||||
const newest = createPromptRecord({
|
||||
id: "aaa-newest",
|
||||
channel: "discord",
|
||||
createdAt: 3000,
|
||||
timeoutAt: 9999,
|
||||
pollIntervalMs: 5000,
|
||||
questions: [],
|
||||
});
|
||||
newest.updatedAt = 3000;
|
||||
newest.status = "answered";
|
||||
writePromptRecord(newest);
|
||||
|
||||
// Record in between
|
||||
const middle = createPromptRecord({
|
||||
id: "mmm-middle",
|
||||
channel: "slack",
|
||||
createdAt: 2000,
|
||||
timeoutAt: 9999,
|
||||
pollIntervalMs: 5000,
|
||||
questions: [],
|
||||
});
|
||||
middle.updatedAt = 2000;
|
||||
writePromptRecord(middle);
|
||||
|
||||
const latest = getLatestPromptSummary();
|
||||
// Should return "aaa-newest" (updatedAt=3000), NOT "zzz-oldest" (alphabetically last)
|
||||
assert.equal(latest?.id, "aaa-newest", "should pick the most recently updated prompt, not the alphabetically last filename");
|
||||
assert.equal(latest?.status, "answered");
|
||||
assert.equal(latest?.updatedAt, 3000);
|
||||
}));
|
||||
36
src/resources/extensions/remote-questions/channels.ts
Normal file
36
src/resources/extensions/remote-questions/channels.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Remote Questions — Adapter pattern interfaces
|
||||
*
|
||||
* Defines the contract for Slack/Discord (or any future) channel adapters.
|
||||
*/
|
||||
|
||||
export interface ChannelAdapter {
|
||||
readonly name: string;
|
||||
sendQuestions(questions: FormattedQuestion[]): Promise<SendResult>;
|
||||
pollResponse(ref: PollReference): Promise<RemoteAnswer | null>;
|
||||
validate(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FormattedQuestion {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
allowMultiple: boolean;
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
ref: PollReference;
|
||||
threadUrl?: string;
|
||||
}
|
||||
|
||||
export interface PollReference {
|
||||
channelType: "slack" | "discord";
|
||||
messageId: string;
|
||||
threadTs?: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface RemoteAnswer {
|
||||
answers: Record<string, { answers: string[]; user_note?: string }>;
|
||||
}
|
||||
81
src/resources/extensions/remote-questions/config.ts
Normal file
81
src/resources/extensions/remote-questions/config.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Remote Questions — configuration resolution and validation
|
||||
*/
|
||||
|
||||
import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js";
|
||||
import type { RemoteChannel } from "./types.js";
|
||||
|
||||
export interface ResolvedConfig {
|
||||
channel: RemoteChannel;
|
||||
channelId: string;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const ENV_KEYS: Record<RemoteChannel, string> = {
|
||||
slack: "SLACK_BOT_TOKEN",
|
||||
discord: "DISCORD_BOT_TOKEN",
|
||||
};
|
||||
|
||||
// Channel ID format validation — prevents SSRF if preferences are attacker-controlled
|
||||
const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
|
||||
slack: /^[A-Z0-9]{9,12}$/,
|
||||
discord: /^\d{17,20}$/,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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 channelId = String(rq.channel_id);
|
||||
if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return null;
|
||||
|
||||
const token = process.env[ENV_KEYS[rq.channel]];
|
||||
if (!token) return null;
|
||||
|
||||
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,
|
||||
timeoutMs: timeoutMinutes * 60 * 1000,
|
||||
pollIntervalMs: pollIntervalSeconds * 1000,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
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.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`;
|
||||
const channelId = String(rq.channel_id);
|
||||
if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return `Remote questions: invalid ${rq.channel} channel ID format`;
|
||||
const envVar = ENV_KEYS[rq.channel];
|
||||
if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`;
|
||||
|
||||
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)`;
|
||||
}
|
||||
|
||||
export function isValidChannelId(channel: RemoteChannel, id: string): boolean {
|
||||
return CHANNEL_ID_PATTERNS[channel].test(id);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
128
src/resources/extensions/remote-questions/discord-adapter.ts
Normal file
128
src/resources/extensions/remote-questions/discord-adapter.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Remote Questions — Discord adapter
|
||||
*/
|
||||
|
||||
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 PER_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
|
||||
|
||||
export class DiscordAdapter implements ChannelAdapter {
|
||||
readonly name = "discord" as const;
|
||||
private botUserId: string | null = null;
|
||||
private readonly token: string;
|
||||
private readonly channelId: string;
|
||||
|
||||
constructor(token: string, channelId: string) {
|
||||
this.token = token;
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
const res = await this.discordApi("GET", "/users/@me");
|
||||
if (!res.id) throw new Error("Discord auth failed: invalid token");
|
||||
this.botUserId = String(res.id);
|
||||
}
|
||||
|
||||
async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
|
||||
const { embeds, reactionEmojis } = formatForDiscord(prompt);
|
||||
const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, {
|
||||
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)}`);
|
||||
|
||||
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: {
|
||||
id: prompt.id,
|
||||
channel: "discord",
|
||||
messageId,
|
||||
channelId: this.channelId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
|
||||
if (!this.botUserId) await this.validate();
|
||||
|
||||
if (prompt.questions.length === 1) {
|
||||
const reactionAnswer = await this.checkReactions(prompt, ref);
|
||||
if (reactionAnswer) return reactionAnswer;
|
||||
}
|
||||
|
||||
return this.checkReplies(prompt, ref);
|
||||
}
|
||||
|
||||
private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
|
||||
const reactions: Array<{ emoji: string; count: number }> = [];
|
||||
for (const emoji of NUMBER_EMOJIS) {
|
||||
try {
|
||||
const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`);
|
||||
if (Array.isArray(users)) {
|
||||
const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId);
|
||||
if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = String((err as Error).message ?? "");
|
||||
// 404 = no reactions for this emoji — expected, continue
|
||||
if (msg.includes("HTTP 404")) continue;
|
||||
// 401/403 = auth failure — surface to caller so it can fail the poll
|
||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) throw err;
|
||||
// Other errors (rate limit, network) — skip this emoji, best-effort
|
||||
}
|
||||
}
|
||||
|
||||
if (reactions.length === 0) return null;
|
||||
return parseDiscordResponse(reactions, null, prompt.questions);
|
||||
}
|
||||
|
||||
private async checkReplies(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
|
||||
const messages = await this.discordApi("GET", `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`);
|
||||
if (!Array.isArray(messages)) return null;
|
||||
|
||||
const replies = messages.filter(
|
||||
(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.content,
|
||||
);
|
||||
|
||||
if (replies.length === 0) return null;
|
||||
return parseDiscordResponse([], String(replies[0].content), prompt.questions);
|
||||
}
|
||||
|
||||
private async discordApi(method: string, path: string, body?: unknown): Promise<any> {
|
||||
const headers: Record<string, string> = { Authorization: `Bot ${this.token}` };
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
init.signal = AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(`${DISCORD_API}${path}`, init);
|
||||
if (response.status === 204) return {};
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
// Limit error body length to avoid leaking verbose Discord error responses
|
||||
const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text;
|
||||
throw new Error(`Discord API HTTP ${response.status}: ${safeText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
163
src/resources/extensions/remote-questions/format.ts
Normal file
163
src/resources/extensions/remote-questions/format.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Remote Questions — payload formatting and parsing helpers
|
||||
*/
|
||||
|
||||
import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js";
|
||||
|
||||
export interface SlackBlock {
|
||||
type: string;
|
||||
text?: { type: string; text: string };
|
||||
elements?: Array<{ type: string; text: string }>;
|
||||
}
|
||||
|
||||
export interface DiscordEmbed {
|
||||
title: string;
|
||||
description: string;
|
||||
color: number;
|
||||
fields: Array<{ name: string; value: string; inline?: boolean }>;
|
||||
footer?: { text: string };
|
||||
}
|
||||
|
||||
const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
|
||||
const MAX_USER_NOTE_LENGTH = 500;
|
||||
|
||||
export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
|
||||
const blocks: SlackBlock[] = [
|
||||
{
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: "GSD needs your input" },
|
||||
},
|
||||
];
|
||||
|
||||
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}.`;
|
||||
if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]);
|
||||
return `${emoji} **${opt.label}** — ${opt.description}`;
|
||||
});
|
||||
|
||||
const footerText = supportsReactions
|
||||
? (q.allowMultiple
|
||||
? "Reply with comma-separated choices (`1,3`) or react with matching numbers"
|
||||
: "Reply with a number or react with the matching number")
|
||||
: `Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`;
|
||||
|
||||
return {
|
||||
title: q.header,
|
||||
description: q.question,
|
||||
color: 0x7c3aed,
|
||||
fields: [{ name: "Options", value: optionLines.join("\n") }],
|
||||
footer: { text: footerText },
|
||||
};
|
||||
});
|
||||
|
||||
return { embeds, reactionEmojis };
|
||||
}
|
||||
|
||||
export function parseSlackReply(text: string, questions: RemoteQuestion[]): RemoteAnswer {
|
||||
const answers: RemoteAnswer["answers"] = {};
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (questions.length === 1) {
|
||||
answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]);
|
||||
return { answers };
|
||||
}
|
||||
|
||||
const parts = trimmed.includes(";")
|
||||
? 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++) {
|
||||
answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]);
|
||||
}
|
||||
|
||||
return { answers };
|
||||
}
|
||||
|
||||
export function parseDiscordResponse(
|
||||
reactions: Array<{ emoji: string; count: number }>,
|
||||
replyText: string | null,
|
||||
questions: RemoteQuestion[],
|
||||
): RemoteAnswer {
|
||||
if (replyText) return parseSlackReply(replyText, questions);
|
||||
|
||||
const answers: RemoteAnswer["answers"] = {};
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } {
|
||||
if (!text) return { answers: [], user_note: "No response provided" };
|
||||
|
||||
if (/^[\d,\s]+$/.test(text)) {
|
||||
const nums = text
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.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);
|
||||
return { answers: q.allowMultiple ? selected : [selected[0]] };
|
||||
}
|
||||
}
|
||||
|
||||
const single = parseInt(text, 10);
|
||||
if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) {
|
||||
return { answers: [q.options[single - 1].label] };
|
||||
}
|
||||
|
||||
return { answers: [], user_note: truncateNote(text) };
|
||||
}
|
||||
|
||||
function truncateNote(text: string): string {
|
||||
return text.length > MAX_USER_NOTE_LENGTH ? text.slice(0, MAX_USER_NOTE_LENGTH) + "…" : text;
|
||||
}
|
||||
187
src/resources/extensions/remote-questions/manager.ts
Normal file
187
src/resources/extensions/remote-questions/manager.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<ToolResult | null> {
|
||||
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", sanitizeError(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", sanitizeError(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<RemoteAnswer | null> {
|
||||
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", sanitizeError(String((err as Error).message)));
|
||||
return null;
|
||||
}
|
||||
|
||||
await sleep(prompt.pollIntervalMs, signal);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
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<string, { answers: string[] }> {
|
||||
const out: Record<string, { answers: string[] }> = {};
|
||||
for (const [id, data] of Object.entries(answer.answers)) {
|
||||
const list = [...data.answers];
|
||||
if (data.user_note) list.push(`user_note: ${data.user_note}`);
|
||||
out[id] = { answers: list };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip token-like strings from error messages before surfacing
|
||||
const TOKEN_PATTERNS = [
|
||||
/xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens
|
||||
/xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens
|
||||
/xoxa-[A-Za-z0-9\-]+/g, // Slack app tokens
|
||||
/[A-Za-z0-9_\-.]{20,}/g, // Long opaque secrets (Discord tokens, etc.)
|
||||
];
|
||||
|
||||
export function sanitizeError(msg: string): string {
|
||||
let sanitized = msg;
|
||||
for (const pattern of TOKEN_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, "[REDACTED]");
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function errorResult(message: string, channel: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: sanitizeError(message) }],
|
||||
details: { remote: true, channel, error: true, status: "failed" },
|
||||
};
|
||||
}
|
||||
303
src/resources/extensions/remote-questions/remote-command.ts
Normal file
303
src/resources/extensions/remote-questions/remote-command.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* Remote Questions — /gsd remote command
|
||||
*/
|
||||
|
||||
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 { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js";
|
||||
import { getRemoteConfigStatus, resolveRemoteConfig } from "./config.js";
|
||||
import { getLatestPromptSummary } from "./status.js";
|
||||
|
||||
export async function handleRemote(
|
||||
subcommand: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
_pi: ExtensionAPI,
|
||||
): Promise<void> {
|
||||
const trimmed = subcommand.trim();
|
||||
|
||||
if (trimmed === "slack") return handleSetupSlack(ctx);
|
||||
if (trimmed === "discord") return handleSetupDiscord(ctx);
|
||||
if (trimmed === "status") return handleRemoteStatus(ctx);
|
||||
if (trimmed === "disconnect") return handleDisconnect(ctx);
|
||||
|
||||
return handleRemoteMenu(ctx);
|
||||
}
|
||||
|
||||
async function handleSetupSlack(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token");
|
||||
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");
|
||||
|
||||
ctx.ui.notify("Validating token...", "info");
|
||||
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");
|
||||
|
||||
const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
|
||||
if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info");
|
||||
|
||||
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");
|
||||
|
||||
saveProviderToken("slack_bot", token);
|
||||
process.env.SLACK_BOT_TOKEN = token;
|
||||
saveRemoteQuestionsConfig("slack", channelId);
|
||||
ctx.ui.notify(`Slack connected — remote questions enabled for channel ${channelId}.`, "info");
|
||||
}
|
||||
|
||||
async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token");
|
||||
if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info");
|
||||
|
||||
ctx.ui.notify("Validating token...", "info");
|
||||
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");
|
||||
|
||||
const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)");
|
||||
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");
|
||||
}
|
||||
|
||||
saveProviderToken("discord_bot", token);
|
||||
process.env.DISCORD_BOT_TOKEN = token;
|
||||
saveRemoteQuestionsConfig("discord", channelId);
|
||||
ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info");
|
||||
}
|
||||
|
||||
async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const status = getRemoteConfigStatus();
|
||||
const config = resolveRemoteConfig();
|
||||
if (!config) {
|
||||
ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info");
|
||||
return;
|
||||
}
|
||||
|
||||
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()}`);
|
||||
}
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
async function handleDisconnect(ctx: ExtensionCommandContext): Promise<void> {
|
||||
const prefs = loadEffectiveGSDPreferences();
|
||||
const channel = prefs?.preferences.remote_questions?.channel;
|
||||
if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info");
|
||||
|
||||
removeRemoteQuestionsConfig();
|
||||
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");
|
||||
}
|
||||
|
||||
async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
|
||||
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",
|
||||
];
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
if (line.startsWith(CURSOR_MARKER, i)) {
|
||||
output += CURSOR_MARKER;
|
||||
i += CURSOR_MARKER.length;
|
||||
continue;
|
||||
}
|
||||
const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
|
||||
if (ansiMatch) {
|
||||
output += ansiMatch[0];
|
||||
i += ansiMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
output += line[i] === " " ? " " : "*";
|
||||
i += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise<string | null> {
|
||||
if (!ctx.hasUI) return null;
|
||||
return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
|
||||
let cachedLines: string[] | undefined;
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (s: string) => theme.fg("accent", s),
|
||||
selectList: {
|
||||
selectedPrefix: (t: string) => theme.fg("accent", t),
|
||||
selectedText: (t: string) => theme.fg("accent", t),
|
||||
description: (t: string) => theme.fg("muted", t),
|
||||
scrollInfo: (t: string) => theme.fg("dim", t),
|
||||
noMatch: (t: string) => theme.fg("warning", t),
|
||||
},
|
||||
};
|
||||
const editor = new Editor(tui, editorTheme, { paddingX: 1 });
|
||||
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", "─".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)));
|
||||
lines.push("");
|
||||
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<string | null> {
|
||||
if (!ctx.hasUI) return null;
|
||||
return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
|
||||
let cachedLines: string[] | undefined;
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (s: string) => theme.fg("accent", s),
|
||||
selectList: {
|
||||
selectedPrefix: (t: string) => theme.fg("accent", t),
|
||||
selectedText: (t: string) => theme.fg("accent", t),
|
||||
description: (t: string) => theme.fg("muted", t),
|
||||
scrollInfo: (t: string) => theme.fg("dim", t),
|
||||
noMatch: (t: string) => theme.fg("warning", t),
|
||||
},
|
||||
};
|
||||
const editor = new Editor(tui, editorTheme, { paddingX: 1 });
|
||||
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", "─".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));
|
||||
lines.push("");
|
||||
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; } };
|
||||
});
|
||||
}
|
||||
213
src/resources/extensions/remote-questions/send.ts
Normal file
213
src/resources/extensions/remote-questions/send.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Remote Questions — Entry point
|
||||
*
|
||||
* Transparent routing: when ctx.hasUI is false and a remote channel is
|
||||
* configured, sends questions via Slack/Discord and polls for the response.
|
||||
*
|
||||
* The LLM keeps calling `ask_user_questions` as normal — this module
|
||||
* intercepts the non-interactive branch.
|
||||
*/
|
||||
|
||||
import type { FormattedQuestion, ChannelAdapter, RemoteAnswer } from "./channels.js";
|
||||
import { resolveRemoteConfig, type ResolvedConfig } from "./config.js";
|
||||
import { SlackAdapter } from "./slack-adapter.js";
|
||||
import { DiscordAdapter } from "./discord-adapter.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Try to send questions via a remote channel (Slack/Discord).
|
||||
* Returns a formatted ToolResult if successful, or null if no remote
|
||||
* channel is configured (caller falls back to the original error).
|
||||
*/
|
||||
export async function tryRemoteQuestions(
|
||||
questions: Question[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToolResult | null> {
|
||||
const config = resolveRemoteConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const adapter = createAdapter(config);
|
||||
const formatted = questionsToFormatted(questions);
|
||||
|
||||
try {
|
||||
await adapter.validate();
|
||||
} catch (err) {
|
||||
return errorToolResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
let sendResult;
|
||||
try {
|
||||
sendResult = await adapter.sendQuestions(formatted);
|
||||
} catch (err) {
|
||||
return errorToolResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const threadInfo = sendResult.threadUrl
|
||||
? ` Thread: ${sendResult.threadUrl}`
|
||||
: "";
|
||||
|
||||
// Poll for response
|
||||
const answer = await pollWithTimeout(adapter, sendResult.ref, formatted, signal, config);
|
||||
|
||||
if (!answer) {
|
||||
// Timeout — return structured result so the LLM knows
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
timed_out: true,
|
||||
channel: config.channel,
|
||||
timeout_minutes: config.timeoutMs / 60000,
|
||||
thread_url: sendResult.threadUrl ?? null,
|
||||
message: `User did not respond within ${config.timeoutMs / 60000} minutes.${threadInfo}`,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
remote: true,
|
||||
channel: config.channel,
|
||||
timed_out: true,
|
||||
threadUrl: sendResult.threadUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Format the answer in the same structure as formatForLLM
|
||||
const formattedAnswer = formatRemoteAnswerForLLM(answer);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formattedAnswer }],
|
||||
details: {
|
||||
remote: true,
|
||||
channel: config.channel,
|
||||
timed_out: false,
|
||||
threadUrl: sendResult.threadUrl,
|
||||
questions,
|
||||
response: answer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createAdapter(config: ResolvedConfig): ChannelAdapter & {
|
||||
pollResponseWithQuestions?: (
|
||||
ref: import("./channels.js").PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
) => Promise<RemoteAnswer | null>;
|
||||
} {
|
||||
switch (config.channel) {
|
||||
case "slack":
|
||||
return new SlackAdapter(config.token, config.channelId);
|
||||
case "discord":
|
||||
return new DiscordAdapter(config.token, config.channelId);
|
||||
default:
|
||||
throw new Error(`Unknown channel type: ${config.channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWithTimeout(
|
||||
adapter: ReturnType<typeof createAdapter>,
|
||||
ref: import("./channels.js").PollReference,
|
||||
questions: FormattedQuestion[],
|
||||
signal: AbortSignal | undefined,
|
||||
config: ResolvedConfig,
|
||||
): Promise<RemoteAnswer | null> {
|
||||
const deadline = Date.now() + config.timeoutMs;
|
||||
let retries = 0;
|
||||
const maxNetworkRetries = 1;
|
||||
|
||||
while (Date.now() < deadline && !signal?.aborted) {
|
||||
try {
|
||||
// Use the question-aware poll if available
|
||||
const answer = adapter.pollResponseWithQuestions
|
||||
? await adapter.pollResponseWithQuestions(ref, questions)
|
||||
: await adapter.pollResponse(ref);
|
||||
|
||||
if (answer) return answer;
|
||||
retries = 0; // Reset on successful poll
|
||||
} catch {
|
||||
retries++;
|
||||
if (retries > maxNetworkRetries) return null;
|
||||
}
|
||||
|
||||
await sleep(config.pollIntervalMs, signal);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onAbort = () => settle();
|
||||
const timer = setTimeout(() => settle(), ms);
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function questionsToFormatted(questions: Question[]): FormattedQuestion[] {
|
||||
return questions.map((q) => ({
|
||||
id: q.id,
|
||||
header: q.header,
|
||||
question: q.question,
|
||||
options: q.options,
|
||||
allowMultiple: q.allowMultiple ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RemoteAnswer into the same JSON structure as the local formatForLLM.
|
||||
* Structure: { answers: { [id]: { answers: string[] } } }
|
||||
*/
|
||||
function formatRemoteAnswerForLLM(answer: RemoteAnswer): string {
|
||||
const formatted: Record<string, { answers: string[] }> = {};
|
||||
for (const [id, data] of Object.entries(answer.answers)) {
|
||||
const list = [...data.answers];
|
||||
if (data.user_note) {
|
||||
list.push(`user_note: ${data.user_note}`);
|
||||
}
|
||||
formatted[id] = { answers: list };
|
||||
}
|
||||
return JSON.stringify({ answers: formatted });
|
||||
}
|
||||
|
||||
function errorToolResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { remote: true, error: true },
|
||||
};
|
||||
}
|
||||
92
src/resources/extensions/remote-questions/slack-adapter.ts
Normal file
92
src/resources/extensions/remote-questions/slack-adapter.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Remote Questions — Slack adapter
|
||||
*/
|
||||
|
||||
import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
|
||||
import { formatForSlack, parseSlackReply } from "./format.js";
|
||||
|
||||
const SLACK_API = "https://slack.com/api";
|
||||
const PER_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class SlackAdapter implements ChannelAdapter {
|
||||
readonly name = "slack" as const;
|
||||
private botUserId: string | null = null;
|
||||
private readonly token: string;
|
||||
private readonly channelId: string;
|
||||
|
||||
constructor(token: string, channelId: string) {
|
||||
this.token = token;
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
const res = await this.slackApi("auth.test", {});
|
||||
if (!res.ok) throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`);
|
||||
this.botUserId = String(res.user_id ?? "");
|
||||
}
|
||||
|
||||
async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
|
||||
const res = await this.slackApi("chat.postMessage", {
|
||||
channel: this.channelId,
|
||||
text: "GSD needs your input",
|
||||
blocks: formatForSlack(prompt),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`);
|
||||
|
||||
const ts = String(res.ts);
|
||||
const channel = String(res.channel);
|
||||
return {
|
||||
ref: {
|
||||
id: prompt.id,
|
||||
channel: "slack",
|
||||
messageId: ts,
|
||||
threadTs: ts,
|
||||
channelId: channel,
|
||||
threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
|
||||
if (!this.botUserId) await this.validate();
|
||||
|
||||
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 && m.user !== this.botUserId && m.text);
|
||||
if (userReplies.length === 0) return null;
|
||||
|
||||
return parseSlackReply(String(userReplies[0].text), prompt.questions);
|
||||
}
|
||||
|
||||
private async slackApi(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const url = `${SLACK_API}/${method}`;
|
||||
const isGet = method === "conversations.replies" || method === "auth.test";
|
||||
|
||||
let response: Response;
|
||||
if (isGet) {
|
||||
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}` }, signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS) });
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`);
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
31
src/resources/extensions/remote-questions/status.ts
Normal file
31
src/resources/extensions/remote-questions/status.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 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"));
|
||||
if (files.length === 0) return null;
|
||||
|
||||
let latest: LatestPromptSummary | null = null;
|
||||
for (const file of files) {
|
||||
const record = readPromptRecord(file.replace(/\.json$/, ""));
|
||||
if (!record) continue;
|
||||
if (!latest || record.updatedAt > latest.updatedAt) {
|
||||
latest = { id: record.id, status: record.status, updatedAt: record.updatedAt };
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
77
src/resources/extensions/remote-questions/store.ts
Normal file
77
src/resources/extensions/remote-questions/store.ts
Normal file
|
|
@ -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>,
|
||||
): 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 } : {}),
|
||||
});
|
||||
}
|
||||
75
src/resources/extensions/remote-questions/types.ts
Normal file
75
src/resources/extensions/remote-questions/types.ts
Normal file
|
|
@ -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<string, { answers: string[]; user_note?: string }>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult>;
|
||||
pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null>;
|
||||
}
|
||||
|
|
@ -83,6 +83,8 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void {
|
|||
['brave_answers', 'BRAVE_ANSWERS_KEY'],
|
||||
['context7', 'CONTEXT7_API_KEY'],
|
||||
['jina', 'JINA_API_KEY'],
|
||||
['slack_bot', 'SLACK_BOT_TOKEN'],
|
||||
['discord_bot', 'DISCORD_BOT_TOKEN'],
|
||||
]
|
||||
for (const [provider, envVar] of providers) {
|
||||
if (!process.env[envVar]) {
|
||||
|
|
@ -133,6 +135,20 @@ const API_KEYS: ApiKeyConfig[] = [
|
|||
hint: '(clean page extraction)',
|
||||
description: 'High-quality web page content extraction',
|
||||
},
|
||||
{
|
||||
provider: 'slack_bot',
|
||||
envVar: 'SLACK_BOT_TOKEN',
|
||||
label: 'Slack Bot',
|
||||
hint: '(remote questions in auto-mode)',
|
||||
description: 'Bot token for remote questions via Slack',
|
||||
},
|
||||
{
|
||||
provider: 'discord_bot',
|
||||
envVar: 'DISCORD_BOT_TOKEN',
|
||||
label: 'Discord Bot',
|
||||
hint: '(remote questions in auto-mode)',
|
||||
description: 'Bot token for remote questions via Discord',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue