diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..f6b11f8 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +bun run lint +bun run typecheck +bun test diff --git a/bun.lock b/bun.lock index d0c6b48..48bf595 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.6", "@types/bun": "latest", + "husky": "^9.1.7", }, "peerDependencies": { "typescript": "^5", @@ -162,6 +163,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], diff --git a/package.json b/package.json index af6d673..0feb408 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,16 @@ "test": "bun test", "lint": "bunx biome check src/ tests/", "lint:fix": "bunx biome check --write src/ tests/", - "format": "bunx biome format --write src/ tests/" + "format": "bunx biome format --write src/ tests/", + "typecheck": "bunx tsc --noEmit", + "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "^2.4.6", - "@types/bun": "latest" + "@types/bun": "latest", + "husky": "^9.1.7" }, + "trustedDependencies": ["husky"], "peerDependencies": { "typescript": "^5" }, diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 2c5aee1..a42443d 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -4,8 +4,26 @@ import type { CCSlackConfig } from "../config"; import { buildPrompt } from "../prompt/template"; import type { TaskQueue } from "../queue/taskQueue"; import { parseMessage } from "./parser"; +import type { SlackMessage } from "./responder"; import { formatMentionReply, formatSessionInfo } from "./responder"; +async function postReplyOrEphemeral( + client: any, + { + channel, + threadTs, + user, + noreply, + }: { channel: string; threadTs: string | undefined; user: string; noreply: boolean }, + payload: SlackMessage | { text: string }, +): Promise { + if (noreply) { + await client.chat.postEphemeral({ channel, user, ...(threadTs && { thread_ts: threadTs }), ...payload }); + } else { + await client.chat.postMessage({ channel, thread_ts: threadTs, ...payload }); + } +} + export interface ResolvedRepo { repoName: string; repoPath: string; @@ -103,10 +121,10 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { return; } - const { repo, model, session: parsedSession, prompt } = parseMessage(event.text || ""); + const { repo, model, session: parsedSession, noreply, prompt } = parseMessage(event.text || ""); const resolvedModel = model ?? config.defaultModel; console.log( - `[mention] New request from ${event.user} | repo: ${repo ?? "(default)"} | model: ${resolvedModel ?? "(default)"} | prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`, + `[mention] New request from ${event.user} | repo: ${repo ?? "(default)"} | model: ${resolvedModel ?? "(default)"} | noreply: ${noreply} | prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`, ); let resolved: ResolvedRepo; @@ -168,15 +186,22 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .add({ channel: event.channel, timestamp: event.ts, name: result.success ? "white_check_mark" : "x" }) .catch(() => {}), ]); - const messages = formatMentionReply(result); - for (const msg of messages) { - await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...msg }); + // noreply: skip thread reply, send session info only to requester via ephemeral + if (!noreply) { + const messages = formatMentionReply(result); + for (const msg of messages) { + await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...msg }); + } } - // Post session info for local resume + // Post session info if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await client.chat.postMessage({ channel: event.channel, thread_ts: event.ts, ...sessionMsg }); + await postReplyOrEphemeral( + client, + { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, + sessionMsg, + ); } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -188,11 +213,11 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { .remove({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), client.reactions.add({ channel: event.channel, timestamp: event.ts, name: "x" }).catch(() => {}), - client.chat.postMessage({ - channel: event.channel, - thread_ts: event.ts, - text: "오류가 발생했습니다. 서버 로그를 확인해주세요.", - }), + postReplyOrEphemeral( + client, + { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, + { text: "오류가 발생했습니다. 서버 로그를 확인해주세요." }, + ), ]); } }; diff --git a/src/slack/parser.ts b/src/slack/parser.ts index 2bc5e1b..baa1405 100644 --- a/src/slack/parser.ts +++ b/src/slack/parser.ts @@ -2,6 +2,7 @@ export interface ParsedMessage { repo: string | null; model: string | null; session: string | null; + noreply: boolean; prompt: string; } @@ -20,7 +21,10 @@ export function parseMessage(text: string): ParsedMessage { const { value: model, remaining: r2 } = extractPrefix(r1, "model"); const { value: session, remaining: r3 } = extractPrefix(r2, "session"); - const prompt = r3.replace(/\s+/g, " ").trim(); + const r4 = r3.replace(/\bnoreply\b/, "").trim(); + const noreply = r4 !== r3; - return { repo, model, session, prompt }; + const prompt = r4.replace(/\s+/g, " ").trim(); + + return { repo, model, session, noreply, prompt }; } diff --git a/tests/slack/parser.test.ts b/tests/slack/parser.test.ts index 9031b8c..06db139 100644 --- a/tests/slack/parser.test.ts +++ b/tests/slack/parser.test.ts @@ -82,3 +82,49 @@ describe("parseMessage", () => { expect(result.prompt).toBe("add tests"); }); }); + +describe("noreply flag", () => { + it("parses noreply keyword", () => { + const result = parseMessage("noreply repo:my-project run tests"); + expect(result.noreply).toBe(true); + expect(result.repo).toBe("my-project"); + expect(result.prompt).toBe("run tests"); + }); + + it("noreply is false when not present", () => { + const result = parseMessage("repo:my-project run tests"); + expect(result.noreply).toBe(false); + }); + + it("noreply works after bot mention", () => { + const result = parseMessage("<@U12345> noreply fix the bug"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("fix the bug"); + }); + + it("noreply does not appear in prompt", () => { + const result = parseMessage("noreply do something"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("do something"); + expect(result.prompt).not.toContain("noreply"); + }); + + it("noreply in the middle of message", () => { + const result = parseMessage("run noreply tests"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("run tests"); + }); + + it("noreply at the end of message", () => { + const result = parseMessage("run tests noreply"); + expect(result.noreply).toBe(true); + expect(result.prompt).toBe("run tests"); + }); + + it("noreply combined with model prefix", () => { + const result = parseMessage("noreply model:opus run tests"); + expect(result.noreply).toBe(true); + expect(result.model).toBe("opus"); + expect(result.prompt).toBe("run tests"); + }); +});