From 5fec6ea81e968fb56b3230496b45d0fc0b6197ff Mon Sep 17 00:00:00 2001 From: Colin Johnson Date: Mon, 16 Mar 2026 11:01:41 -0400 Subject: [PATCH] enhance: bring Slack remote questions to parity (#628) * enhance: bring Slack remote questions to parity * chore(M004): record integration branch * fix: restore remote questions adapter import --- .gsd/milestones/M004/M004-META.json | 2 +- docs/remote-questions.md | 16 +-- .../gsd/tests/remote-questions.test.ts | 97 ++++++++++++++++++- .../gsd/tests/stop-auto-remote.test.ts | 39 ++++++-- .../remote-questions/discord-adapter.ts | 6 +- .../extensions/remote-questions/format.ts | 71 ++++++++++++-- .../extensions/remote-questions/manager.ts | 8 +- .../remote-questions/remote-command.ts | 69 ++++++++++++- .../remote-questions/slack-adapter.ts | 60 +++++++++++- .../extensions/remote-questions/types.ts | 1 + 10 files changed, 331 insertions(+), 38 deletions(-) diff --git a/.gsd/milestones/M004/M004-META.json b/.gsd/milestones/M004/M004-META.json index b657e9119..703c2c2b2 100644 --- a/.gsd/milestones/M004/M004-META.json +++ b/.gsd/milestones/M004/M004-META.json @@ -1,3 +1,3 @@ { - "integrationBranch": "main" + "integrationBranch": "Solvely/slack-remote-parity" } diff --git a/docs/remote-questions.md b/docs/remote-questions.md index 2f5ce2e29..ea84bbd70 100644 --- a/docs/remote-questions.md +++ b/docs/remote-questions.md @@ -36,14 +36,14 @@ The setup wizard: The setup wizard: 1. Prompts for your Slack bot token (`xoxb-...`) 2. Validates the token -3. Prompts for a channel ID +3. Lists channels the bot can access (with manual ID fallback) 4. Sends a test message to confirm permissions 5. Saves the configuration **Bot requirements:** - A Slack app with a bot token (from [Slack API](https://api.slack.com/apps)) - Bot must be invited to the target channel -- Required scopes: `chat:write`, `reactions:read`, `channels:history` +- Typical scopes for public/private channels: `chat:write`, `reactions:read`, `reactions:write`, `channels:read`, `groups:read`, `channels:history`, `groups:history` ## Configuration @@ -66,12 +66,12 @@ remote_questions: - **Reacting** with a number emoji (1️⃣, 2️⃣, etc.) for single-question prompts - **Replying** to the message with a number (`1`), comma-separated numbers (`1,3`), or free text 5. GSD picks up the response and continues execution -6. On Discord, a ✅ reaction is added to the prompt message to confirm receipt +6. A ✅ reaction is added to the prompt message to confirm receipt ### Response Formats **Single question:** -- React with a number emoji (Discord only, single-question prompts) +- React with a number emoji (single-question prompts) - Reply with a number: `2` - Reply with free text (captured as a user note) @@ -98,13 +98,13 @@ If no response is received within `timeout_minutes`, the prompt times out and GS | Feature | Discord | Slack | |---------|---------|-------| | Rich message format | Embeds with fields | Block Kit | -| Reaction-based answers | ✅ (single-question) | ❌ | +| Reaction-based answers | ✅ (single-question) | ✅ (single-question) | | Thread-based replies | Message replies | Thread replies | | Message URL in logs | ✅ | ✅ | -| Answer acknowledgement | ✅ reaction on receipt | Thread context | +| Answer acknowledgement | ✅ reaction on receipt | ✅ reaction on receipt | | Multi-question support | Text replies (semicolons/newlines) | Text replies (semicolons/newlines) | -| Context source in prompt | ✅ (footer) | ❌ | -| Server/channel picker | ✅ (interactive) | Manual channel ID | +| Context source in prompt | ✅ (footer) | ✅ (context block) | +| Server/channel picker | ✅ (interactive) | ✅ (interactive + manual fallback) | | Token validation | ✅ | ✅ | | Test message on setup | ✅ | ✅ | diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 850ca4274..4c30c81a2 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { parseSlackReply, parseDiscordResponse, formatForDiscord } from "../../remote-questions/format.ts"; +import { parseSlackReply, parseDiscordResponse, formatForDiscord, formatForSlack, parseSlackReactionResponse } from "../../remote-questions/format.ts"; import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; import { sanitizeError } from "../../remote-questions/manager.ts"; @@ -94,6 +94,21 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { assert.match(String(result.answers.second.user_note), /single-question prompts/i); }); +test("parseSlackReactionResponse handles single-question reactions", () => { + const result = parseSlackReactionResponse(["two"], [{ + 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 truncates user_note longer than 500 chars", () => { const longText = "x".repeat(600); const result = parseSlackReply(longText, [{ @@ -189,6 +204,65 @@ test("formatForDiscord includes context source in footer when present", () => { assert.ok(embeds[0].footer?.text.includes("auto-mode-dispatch"), "footer should include context source"); }); +test("formatForSlack includes context source when present", () => { + const blocks = formatForSlack({ + id: "slack-1", + channel: "slack", + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + context: { source: "ask_user_questions" }, + questions: [{ + id: "q1", + header: "Confirm", + question: "Proceed?", + options: [ + { label: "Yes", description: "Continue" }, + { label: "No", description: "Stop" }, + ], + allowMultiple: false, + }], + }); + + const sourceBlock = blocks.find((block) => block.type === "context" && block.elements?.some((el) => el.text.includes("Source:"))); + assert.ok(sourceBlock, "Slack blocks should include a context source block"); +}); + +test("formatForSlack multi-question prompts explain semicolon and newline reply format", () => { + const blocks = formatForSlack({ + id: "slack-2", + channel: "slack", + createdAt: Date.now(), + timeoutAt: Date.now() + 60000, + pollIntervalMs: 5000, + questions: [ + { + id: "q1", + header: "First", + question: "Pick one", + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + allowMultiple: false, + }, + { + id: "q2", + header: "Second", + question: "Explain", + options: [ + { label: "Gamma", description: "G" }, + { label: "Delta", description: "D" }, + ], + allowMultiple: false, + }, + ], + }); + + const instructionBlock = blocks.find((block) => block.type === "context" && block.elements?.some((el) => el.text.includes("one line per question"))); + assert.ok(instructionBlock, "Slack multi-question prompts should explain one-line or semicolon reply format"); +}); + test("formatForDiscord omits source from footer when context is absent", () => { const prompt = { id: "test-2", @@ -356,6 +430,27 @@ test("DiscordAdapter source-level: acknowledgeAnswer method exists", () => { assert.ok(adapterSrc.includes("✅"), "should use checkmark emoji for acknowledgement"); }); +test("SlackAdapter source-level: supports reaction polling and acknowledgement", () => { + const adapterSrc = readFileSync( + join(__dirname, "..", "..", "remote-questions", "slack-adapter.ts"), + "utf-8", + ); + assert.ok(adapterSrc.includes("reactions.get"), "should poll Slack reactions"); + assert.ok(adapterSrc.includes("reactions.add"), "should add Slack reactions"); + assert.ok(adapterSrc.includes("async acknowledgeAnswer"), "should acknowledge Slack answers"); + assert.ok(adapterSrc.includes("white_check_mark"), "should use a checkmark acknowledgement reaction"); +}); + +test("Slack setup source-level: offers channel picker with manual fallback", () => { + const commandSrc = readFileSync( + join(__dirname, "..", "..", "remote-questions", "remote-command.ts"), + "utf-8", + ); + assert.ok(commandSrc.includes("users.conversations"), "Slack setup should query Slack channels"); + assert.ok(commandSrc.includes("Select a Slack channel"), "Slack setup should present a channel picker"); + assert.ok(commandSrc.includes("Enter channel ID manually"), "Slack setup should preserve manual fallback"); +}); + test("DiscordAdapter source-level: resolves guild ID for message URLs", () => { const adapterSrc = readFileSync( join(__dirname, "..", "..", "remote-questions", "discord-adapter.ts"), diff --git a/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts b/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts index d613775df..8a8dd02d7 100644 --- a/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +++ b/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts @@ -4,7 +4,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { fork } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { writeFileSync } from "node:fs"; import { @@ -25,6 +25,27 @@ function cleanup(base: string): void { try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } } +function waitForChildExit(child: ChildProcess, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null) { + resolve(child.exitCode); + return; + } + + const timeout = setTimeout(() => { + child.off("exit", onExit); + resolve(child.exitCode); + }, timeoutMs); + + const onExit = (code: number | null) => { + clearTimeout(timeout); + resolve(code); + }; + + child.once("exit", onExit); + }); +} + // ─── stopAutoRemote ────────────────────────────────────────────────────── test("stopAutoRemote returns found:false when no lock file exists", () => { @@ -63,12 +84,16 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as const base = makeTmpBase(); // Spawn a child process that sleeps, acting as a fake auto-mode session - const child = fork( - "-e", - ["process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"], + const child = spawn( + process.execPath, + ["-e", "process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"], { stdio: "ignore", detached: false }, ); + if (!child.pid) { + throw new Error("failed to spawn child process for stopAutoRemote test"); + } + try { // Wait for child to be ready await new Promise((resolve) => setTimeout(resolve, 200)); @@ -84,15 +109,13 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as }; writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2), "utf-8"); + const exitPromise = waitForChildExit(child); const result = stopAutoRemote(base); assert.equal(result.found, true, "should find running auto-mode"); assert.equal(result.pid, child.pid, "should return the PID"); // Wait for child to exit (it should receive SIGTERM) - const exitCode = await new Promise((resolve) => { - child.on("exit", (code) => resolve(code)); - setTimeout(() => resolve(null), 5000); - }); + const exitCode = await exitPromise; // On Windows, SIGTERM is not interceptable — the process exits with code 1 // rather than running the handler. Accept either clean exit (0) or forced (1). assert.ok(exitCode !== null, "child should have exited after SIGTERM"); diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index e2c66409f..199e00386 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -3,12 +3,10 @@ */ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; -import { formatForDiscord, parseDiscordResponse } from "./format.js"; +import { formatForDiscord, parseDiscordResponse, DISCORD_NUMBER_EMOJIS } 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; @@ -102,7 +100,7 @@ export class DiscordAdapter implements ChannelAdapter { private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { const reactions: Array<{ emoji: string; count: number }> = []; - for (const emoji of NUMBER_EMOJIS) { + for (const emoji of DISCORD_NUMBER_EMOJIS) { try { const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`); if (Array.isArray(users)) { diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index 6dd61712e..ba0065d67 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -18,7 +18,8 @@ export interface DiscordEmbed { footer?: { text: string }; } -const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; +export const DISCORD_NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; +export const SLACK_NUMBER_REACTION_NAMES = ["one", "two", "three", "four", "five"]; const MAX_USER_NOTE_LENGTH = 500; export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { @@ -29,7 +30,18 @@ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { }, ]; + if (prompt.questions.length > 1) { + blocks.push({ + type: "context", + elements: [{ + type: "mrkdwn", + text: "Reply once in thread using one line per question or semicolons (`1; 2; custom note`).", + }], + }); + } + for (const q of prompt.questions) { + const supportsReactions = prompt.questions.length === 1; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` }, @@ -47,15 +59,33 @@ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { 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.", + text: prompt.questions.length > 1 + ? (q.allowMultiple + ? "For this question, use comma-separated numbers (`1,3`) or free text." + : "For this question, use one number (`1`) or free text.") + : (q.allowMultiple + ? (supportsReactions + ? "Reply in thread with comma-separated numbers (`1,3`) or react with matching number emoji." + : "Reply in thread with comma-separated numbers (`1,3`) or free text.") + : (supportsReactions + ? "Reply in thread with a number (`1`) or react with the matching number emoji." + : "Reply in thread with a number (`1`) or free text.")), }], }); blocks.push({ type: "divider" }); } + if (prompt.context?.source) { + blocks.push({ + type: "context", + elements: [{ + type: "mrkdwn", + text: `Source: \`${prompt.context.source}\``, + }], + }); + } + return blocks; } @@ -64,8 +94,8 @@ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[] 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]); + const emoji = DISCORD_NUMBER_EMOJIS[i] ?? `${i + 1}.`; + if (supportsReactions && DISCORD_NUMBER_EMOJIS[i]) reactionEmojis.push(DISCORD_NUMBER_EMOJIS[i]); return `${emoji} **${opt.label}** — ${opt.description}`; }); @@ -130,8 +160,33 @@ export function parseDiscordResponse( 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((r) => DISCORD_NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) + .map((r) => q.options[DISCORD_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 }; +} + +export function parseSlackReactionResponse( + reactionNames: string[], + questions: RemoteQuestion[], +): RemoteAnswer { + const answers: RemoteAnswer["answers"] = {}; + if (questions.length !== 1) { + for (const q of questions) { + answers[q.id] = { answers: [], user_note: "Slack reactions are only supported for single-question prompts" }; + } + return { answers }; + } + + const q = questions[0]; + const picked = reactionNames + .filter((name) => SLACK_NUMBER_REACTION_NAMES.includes(name)) + .map((name) => q.options[SLACK_NUMBER_REACTION_NAMES.indexOf(name)]?.label) .filter(Boolean) as string[]; answers[q.id] = picked.length > 0 diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index 47d438980..2ce249598 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -5,8 +5,8 @@ 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 { SlackAdapter } from "./slack-adapter.js"; import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js"; interface ToolResult { @@ -77,10 +77,10 @@ export async function tryRemoteQuestions( markPromptAnswered(prompt.id, answer); - // Acknowledge receipt with a ✅ on Discord (Slack threads are self-evident) - if (config.channel === "discord" && dispatch.ref) { + // Best-effort acknowledgement gives remote users a visible receipt signal. + if (dispatch.ref) { try { - await (adapter as import("./discord-adapter.js").DiscordAdapter).acknowledgeAnswer(dispatch.ref); + await adapter.acknowledgeAnswer?.(dispatch.ref); } catch { /* best-effort */ } } diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index dafc5ac60..27480915e 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -36,9 +36,28 @@ async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { 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)"); + const channels = await listSlackChannels(token); + const MANUAL_OPTION = "Enter channel ID manually"; + let channelId: string; + + if (!channels || channels.length === 0) { + ctx.ui.notify("Could not list Slack channels — falling back to manual entry.", "warning"); + channelId = await promptSlackChannelId(ctx) ?? ""; + } else { + const channelOptions = [...channels.map((channel) => channel.label), MANUAL_OPTION]; + const selectedChannel = await ctx.ui.select("Select a Slack channel", channelOptions); + if (!selectedChannel) return void ctx.ui.notify("Slack setup cancelled.", "info"); + + if (selectedChannel === MANUAL_OPTION) { + channelId = await promptSlackChannelId(ctx) ?? ""; + } else { + const chosen = channels.find((channel) => channel.label === selectedChannel); + if (!chosen) return void ctx.ui.notify("Slack setup cancelled.", "info"); + channelId = chosen.id; + } + } + if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info"); - if (!isValidChannelId("slack", channelId)) return void ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error"); const send = await fetchJson("https://slack.com/api/chat.postMessage", { method: "POST", @@ -203,6 +222,52 @@ async function fetchJson(url: string, init?: RequestInit): Promise { } } +async function listSlackChannels(token: string): Promise | null> { + const headers = { Authorization: `Bearer ${token}` }; + const channels: Array<{ id: string; label: string; name: string }> = []; + let cursor = ""; + + do { + const params = new URLSearchParams({ + exclude_archived: "true", + limit: "200", + types: "public_channel,private_channel", + }); + if (cursor) params.set("cursor", cursor); + + const response = await fetchJson(`https://slack.com/api/users.conversations?${params.toString()}`, { headers }); + if (!response?.ok || !Array.isArray(response.channels)) { + return channels.length > 0 ? channels.map(({ id, label }) => ({ id, label })) : null; + } + + for (const channel of response.channels as Array<{ id?: string; name?: string; is_private?: boolean }>) { + if (!channel.id || !channel.name) continue; + channels.push({ + id: channel.id, + name: channel.name, + label: channel.is_private ? `[private] ${channel.name}` : `#${channel.name}`, + }); + } + + cursor = typeof response.response_metadata?.next_cursor === "string" + ? response.response_metadata.next_cursor + : ""; + } while (cursor); + + channels.sort((a, b) => a.name.localeCompare(b.name)); + return channels.map(({ id, label }) => ({ id, label })); +} + +async function promptSlackChannelId(ctx: ExtensionCommandContext): Promise { + const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); + if (!channelId) return null; + if (!isValidChannelId("slack", channelId)) { + ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error"); + return null; + } + return channelId; +} + function getAuthStorage(): AuthStorage { const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); mkdirSync(dirname(authPath), { recursive: true }); diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 42b9fcc07..d56023bf9 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -3,10 +3,11 @@ */ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; -import { formatForSlack, parseSlackReply } from "./format.js"; +import { formatForSlack, parseSlackReply, parseSlackReactionResponse, SLACK_NUMBER_REACTION_NAMES } from "./format.js"; const SLACK_API = "https://slack.com/api"; const PER_REQUEST_TIMEOUT_MS = 15_000; +const SLACK_ACK_REACTION = "white_check_mark"; export class SlackAdapter implements ChannelAdapter { readonly name = "slack" as const; @@ -36,6 +37,17 @@ export class SlackAdapter implements ChannelAdapter { const ts = String(res.ts); const channel = String(res.channel); + if (prompt.questions.length === 1) { + const reactionNames = SLACK_NUMBER_REACTION_NAMES.slice(0, prompt.questions[0].options.length); + for (const name of reactionNames) { + try { + await this.slackApi("reactions.add", { channel, timestamp: ts, name }); + } catch { + // Best-effort only + } + } + } + return { ref: { id: prompt.id, @@ -51,6 +63,11 @@ export class SlackAdapter implements ChannelAdapter { async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { if (!this.botUserId) await this.validate(); + if (prompt.questions.length === 1) { + const reactionAnswer = await this.checkReactions(prompt, ref); + if (reactionAnswer) return reactionAnswer; + } + const res = await this.slackApi("conversations.replies", { channel: ref.channelId, ts: ref.threadTs!, @@ -66,9 +83,48 @@ export class SlackAdapter implements ChannelAdapter { return parseSlackReply(String(userReplies[0].text), prompt.questions); } + async acknowledgeAnswer(ref: RemotePromptRef): Promise { + try { + await this.slackApi("reactions.add", { + channel: ref.channelId, + timestamp: ref.messageId, + name: SLACK_ACK_REACTION, + }); + } catch { + // Best-effort only + } + } + + private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + const res = await this.slackApi("reactions.get", { + channel: ref.channelId, + timestamp: ref.messageId, + full: "true", + }); + + if (!res.ok) return null; + + const message = (res.message ?? {}) as { + reactions?: Array<{ name?: string; count?: number; users?: string[] }>; + }; + const reactions = Array.isArray(message.reactions) ? message.reactions : []; + const picked = reactions + .filter((reaction) => reaction.name && SLACK_NUMBER_REACTION_NAMES.includes(reaction.name)) + .filter((reaction) => { + const count = Number(reaction.count ?? 0); + const users = Array.isArray(reaction.users) ? reaction.users.map(String) : []; + const botIncluded = this.botUserId ? users.includes(this.botUserId) : false; + return count > (botIncluded ? 1 : 0); + }) + .map((reaction) => String(reaction.name)); + + if (picked.length === 0) return null; + return parseSlackReactionResponse(picked, prompt.questions); + } + private async slackApi(method: string, params: Record): Promise> { const url = `${SLACK_API}/${method}`; - const isGet = method === "conversations.replies" || method === "auth.test"; + const isGet = method === "conversations.replies" || method === "auth.test" || method === "reactions.get"; let response: Response; if (isGet) { diff --git a/src/resources/extensions/remote-questions/types.ts b/src/resources/extensions/remote-questions/types.ts index b1237fdf7..47e859cff 100644 --- a/src/resources/extensions/remote-questions/types.ts +++ b/src/resources/extensions/remote-questions/types.ts @@ -72,4 +72,5 @@ export interface ChannelAdapter { validate(): Promise; sendPrompt(prompt: RemotePrompt): Promise; pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise; + acknowledgeAnswer?(ref: RemotePromptRef): Promise; }