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
This commit is contained in:
parent
2fd4a1da60
commit
5fec6ea81e
10 changed files with 331 additions and 38 deletions
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"integrationBranch": "main"
|
||||
"integrationBranch": "Solvely/slack-remote-parity"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 | ✅ | ✅ |
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<number | null> {
|
||||
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<number | null>((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");
|
||||
|
|
|
|||
|
|
@ -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<RemoteAnswer | null> {
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,9 +36,28 @@ async function handleSetupSlack(ctx: ExtensionCommandContext): Promise<void> {
|
|||
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<any> {
|
|||
}
|
||||
}
|
||||
|
||||
async function listSlackChannels(token: string): Promise<Array<{ id: string; label: string }> | 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<string | null> {
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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<RemoteAnswer | null> {
|
||||
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<void> {
|
||||
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<RemoteAnswer | null> {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -72,4 +72,5 @@ export interface ChannelAdapter {
|
|||
validate(): Promise<void>;
|
||||
sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult>;
|
||||
pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null>;
|
||||
acknowledgeAnswer?(ref: RemotePromptRef): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue