Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Changelog — ChannelGate

- Fix Slack question-card posting by encoding channel membership checks as GET query parameters.

- Let agents ask clarification questions with Slack cards and paged forms: custom option buttons,
Yes/No, multiple selections, and written answers. Save drafts until submission, retain pending
questions across restarts, and continue the requester's thread after they submit.
Expand Down
5 changes: 4 additions & 1 deletion TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
Automated regression: `test/questions.test.js`, `test/question-views.test.js`,
`test/question-interactions.test.js`, `test/question-continuation.test.js`, plus the gateway MCP
inventory/approval, folder settings, busy-thread and recovery suites. The four question suites
pass 38 tests using scratch SQLite, fake Slack interactions and fixture engines. They cover
pass 40 tests using scratch SQLite, fake Slack interactions and fixture engines. They cover
fresh-process draft retrieval, atomic submission/rollback, stale-card repair, serialized rendering,
the Slack acknowledgement deadline, requester authorization, queue/restart recovery, and stop/clear.
Transport regression verifies GET-encoded membership queries (including pagination cursors),
JSON chat writes, authorization headers, and fail-closed API/HTTP errors. Live QST-01 must
post through the real MCP client so request-encoding failures cannot hide behind a fake client.

Run each case separately with Claude and Codex on the exact candidate. Use isolated Slack channels
for Read-only, Worker, Auto, and Admin modes; an approved member is the normal requester and a
Expand Down
14 changes: 9 additions & 5 deletions src/slack/questions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ import { buildQuestionCard, buildQuestionModal, buildCustomAnswerModal, parseQue

// The MCP connection lives in the daemon; no bot credential crosses into the engine container.
export function questionSlackClient({ token = resolveSlackConfig().botToken, fetchImpl = fetch } = {}) {
const call = async (method, body) => {
const call = async (method, body, read = false) => {
if (!token) throw new Error("Slack bot token is not configured.");
const response = await fetchImpl(`https://slack.com/api/${method}`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body), signal: AbortSignal.timeout(20_000),
// conversations.members requires query/form parameters; JSON POSTs lose its channel argument.
const url = new URL(`https://slack.com/api/${method}`);
if (read) url.search = new URLSearchParams(body).toString();
const response = await fetchImpl(url.toString(), {
method: read ? "GET" : "POST",
headers: { Authorization: `Bearer ${token}`, ...(!read ? { "Content-Type": "application/json; charset=utf-8" } : {}) },
...(!read ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(20_000),
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(`Slack ${method} failed: ${data.error || response.status}`);
return data;
};
return { chat: { postMessage: (b) => call("chat.postMessage", b), update: (b) => call("chat.update", b) }, conversations: { members: (b) => call("conversations.members", b) } };
return { chat: { postMessage: (b) => call("chat.postMessage", b), update: (b) => call("chat.update", b) }, conversations: { members: (b) => call("conversations.members", b, true) } };
}

export async function refreshQuestionCard(record, client) {
Expand Down
39 changes: 38 additions & 1 deletion test/question-interactions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ ensureTestEnv();
const { setUser, upsertChannelEntry, saveChannelMeta, getChannelMeta } = await import("../src/config/store.js");
const { getDb } = await import("../src/db/index.js");
const { getQuestion, saveQuestionAnswers } = await import("../src/gateway/questions.js");
const { postQuestions, handleQuestionAction, handleQuestionView, refreshQuestionCard, registerQuestionActions } = await import("../src/slack/questions.js");
const { postQuestions, handleQuestionAction, handleQuestionView, refreshQuestionCard, registerQuestionActions, questionSlackClient } = await import("../src/slack/questions.js");
const { buildQuestionCard, buildQuestionModal, buildCustomAnswerModal } = await import("../src/slack/question-views.js");

const OWNER = "UQUESTION_OWNER";
Expand Down Expand Up @@ -286,3 +286,40 @@ test("slow modal membership verification acknowledges an error before Slack's th
assert.deepEqual(f.current().answers, {});
assert.equal(f.log.continuations.length, 0);
});


test("question Slack transport encodes member queries and preserves JSON chat writes", async () => {
const requests = [];
const client = questionSlackClient({ token: "test-only-token", fetchImpl: async (url, init) => {
requests.push({ url: new URL(url), init });
return { ok: true, json: async () => ({ ok: true, members: ["UOWNER"] }) };
} });
await client.conversations.members({ channel: "CQUESTION", limit: 200, cursor: "next+/=&" });
const { url, init } = requests[0];
assert.equal(url.pathname, "/api/conversations.members");
assert.equal(url.searchParams.get("channel"), "CQUESTION");
assert.equal(url.searchParams.get("limit"), "200");
assert.equal(url.searchParams.get("cursor"), "next+/=&");
assert.equal(init.method, "GET");
assert.equal(init.body, undefined);
assert.equal(init.headers.Authorization, "Bearer test-only-token");
assert.equal(url.toString().includes("test-only-token"), false);
for (const method of ["postMessage", "update"]) {
const payload = { channel: "CQUESTION", text: "Demo" };
await client.chat[method](payload);
const request = requests.at(-1);
assert.equal(request.url.pathname, `/api/chat.${method}`);
assert.equal(request.init.method, "POST");
assert.deepEqual(JSON.parse(request.init.body), payload);
}
});

test("question Slack transport fails closed on API and HTTP errors", async () => {
for (const response of [
{ ok: true, json: async () => ({ ok: false, error: "invalid_arguments" }) },
{ ok: false, status: 503, json: async () => ({}) },
]) {
const client = questionSlackClient({ token: "test-only-token", fetchImpl: async () => response });
await assert.rejects(client.conversations.members({ channel: "CQUESTION" }), /Slack conversations.members failed:/);
}
});
Loading