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
3 changes: 3 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
bun run lint
bun run typecheck
bun test
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
49 changes: 37 additions & 12 deletions src/slack/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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: "오류가 발생했습니다. 서버 로그를 확인해주세요." },
),
]);
}
};
Expand Down
8 changes: 6 additions & 2 deletions src/slack/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface ParsedMessage {
repo: string | null;
model: string | null;
session: string | null;
noreply: boolean;
prompt: string;
}

Expand All @@ -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 };
}
46 changes: 46 additions & 0 deletions tests/slack/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});