From 48c79c88c82411417dfa1376b5b545d5b0f36229 Mon Sep 17 00:00:00 2001 From: jihoson Date: Tue, 17 Feb 2026 06:08:43 +0900 Subject: [PATCH] Unify start/stop slash commands by target --- README.md | 18 ++-- src/lazy_enter/slack_handler.py | 62 +++++++------ tests/test_slack_handler.py | 154 ++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 tests/test_slack_handler.py diff --git a/README.md b/README.md index 1726327..fbb8bd9 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ 1. 로컬에서 Claude 또는 Codex를 tmux 세션으로 미리 실행 - Claude: `tmux new -s claude claude` - Codex: `tmux new -s codex codex` -2. Slack에서 `/start-claude` 또는 `/start-codex` 실행 +2. Slack에서 `/start claude` 또는 `/start codex` 실행 3. 브릿지가 기존 tmux 세션에 attach 4. Slack 채널 메시지가 CLI 입력으로 전달됨 (기본: 엔터 미포함) 5. CLI 출력이 Slack으로 다시 전송됨 -6. `/stop-claude`로 브릿지 연결 해제 (tmux 세션은 유지) +6. `/stop claude` 또는 `/stop codex`로 브릿지 연결 해제 (tmux 세션은 유지) ## 빠른 시작 @@ -37,8 +37,8 @@ cp .env.example .env - `SLACK_ALLOWED_CHANNEL_ID` 선택 환경 변수: -- `TMUX_SESSION_NAME` (기본: `claude`, `/start-claude` 대상) -- `CODEX_TMUX_SESSION_NAME` (기본: `codex`, `/start-codex` 대상) +- `TMUX_SESSION_NAME` (기본: `claude`, `/start claude` 대상) +- `CODEX_TMUX_SESSION_NAME` (기본: `codex`, `/start codex` 대상) - `PTY_READ_TIMEOUT` (기본: `5`) - `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`) - `OUTPUT_SETTLE_SECONDS` (기본: `4.0`, 출력이 잠잠해진 뒤 전송 대기 시간) @@ -55,10 +55,8 @@ cp .env.example .env - Socket Mode 활성화 - Bot Token Scopes에 `chat:write`, `commands` 추가 - Slash Commands 생성: - - `/start-claude` - - `/stop-claude` - - `/start-codex` - - `/stop-codex` + - `/start` + - `/stop` - 앱을 워크스페이스에 설치 후 토큰을 `.env`에 반영 ## 실행 @@ -80,10 +78,10 @@ python -m lazy_enter ``` 실행 후 Slack의 허용된 채널에서: -- `/start-claude`, `/start-codex`: 기존 세션에 연결 +- `/start claude`, `/start codex`: 기존 세션에 연결 - 일반 메시지 전송: 현재 연결된 CLI(Claude/Codex)로 입력만 전달 (엔터 미포함) - `!e`, `!enter` 전송: 엔터 키만 전달 (현재 프롬프트 제출) -- `/stop-claude`, `/stop-codex`: 브릿지 연결 해제 (세션 유지) +- `/stop claude`, `/stop codex`: 브릿지 연결 해제 (세션 유지) ## 테스트 및 품질 점검 diff --git a/src/lazy_enter/slack_handler.py b/src/lazy_enter/slack_handler.py index 84e5a27..0814ed0 100644 --- a/src/lazy_enter/slack_handler.py +++ b/src/lazy_enter/slack_handler.py @@ -58,53 +58,59 @@ class SlackHandler: if self._on_message_callback: self._on_message_callback(text, channel_id) - @self.app.command("/start-claude") - def handle_start_claude(ack: Callable[..., None], body: dict) -> None: + @self.app.command("/start") + def handle_start(ack: Callable[..., None], body: dict) -> None: ack() user_id = body.get("user_id", "") channel_id = body.get("channel_id", "") + target = self._parse_target(body.get("text", "")) if not self._is_authorized(user_id, channel_id): return - if self._on_command_callback: - self._on_command_callback("start", "claude", channel_id) + if target is None: + self.send_message( + channel_id, + ( + ":warning: 대상은 `claude` 또는 `codex`여야 합니다. " + "예: `/start codex`" + ), + ) + return - @self.app.command("/stop-claude") - def handle_stop_claude(ack: Callable[..., None], body: dict) -> None: + if self._on_command_callback: + self._on_command_callback("start", target, channel_id) + + @self.app.command("/stop") + def handle_stop(ack: Callable[..., None], body: dict) -> None: ack() user_id = body.get("user_id", "") channel_id = body.get("channel_id", "") + target = self._parse_target(body.get("text", "")) if not self._is_authorized(user_id, channel_id): return - if self._on_command_callback: - self._on_command_callback("stop", "claude", channel_id) - - @self.app.command("/start-codex") - def handle_start_codex(ack: Callable[..., None], body: dict) -> None: - ack() - user_id = body.get("user_id", "") - channel_id = body.get("channel_id", "") - - if not self._is_authorized(user_id, channel_id): + if target is None: + self.send_message( + channel_id, + ( + ":warning: 대상은 `claude` 또는 `codex`여야 합니다. " + "예: `/stop claude`" + ), + ) return if self._on_command_callback: - self._on_command_callback("start", "codex", channel_id) + self._on_command_callback("stop", target, channel_id) - @self.app.command("/stop-codex") - def handle_stop_codex(ack: Callable[..., None], body: dict) -> None: - ack() - user_id = body.get("user_id", "") - channel_id = body.get("channel_id", "") - - if not self._is_authorized(user_id, channel_id): - return - - if self._on_command_callback: - self._on_command_callback("stop", "codex", channel_id) + @staticmethod + def _parse_target(text: str) -> str | None: + """슬래시 커맨드 인자에서 대상을 파싱한다.""" + first_token = text.strip().split(maxsplit=1)[0].lower() if text.strip() else "" + if first_token in {"claude", "codex"}: + return first_token + return None def on_message(self, callback: Callable[[str, str], None]) -> None: """메시지 수신 콜백을 등록한다.""" diff --git a/tests/test_slack_handler.py b/tests/test_slack_handler.py new file mode 100644 index 0000000..260b665 --- /dev/null +++ b/tests/test_slack_handler.py @@ -0,0 +1,154 @@ +"""Slack 슬래시 커맨드 파싱 테스트.""" + +from __future__ import annotations + +from collections.abc import Callable + +from lazy_enter.config import Config + + +class FakeClient: + def __init__(self) -> None: + self.messages: list[dict[str, str | None]] = [] + + def chat_postMessage( # noqa: N802 + self, channel: str, text: str, thread_ts: str | None = None + ) -> None: + self.messages.append( + { + "channel": channel, + "text": text, + "thread_ts": thread_ts, + } + ) + + +class FakeApp: + def __init__(self, token: str) -> None: + self.token = token + self.client = FakeClient() + self.command_handlers: dict[str, Callable[..., None]] = {} + self.event_handlers: dict[str, Callable[..., None]] = {} + + def event(self, name: str) -> Callable[[Callable[..., None]], Callable[..., None]]: + def decorator(func: Callable[..., None]) -> Callable[..., None]: + self.event_handlers[name] = func + return func + + return decorator + + def command( + self, name: str + ) -> Callable[[Callable[..., None]], Callable[..., None]]: + def decorator(func: Callable[..., None]) -> Callable[..., None]: + self.command_handlers[name] = func + return func + + return decorator + + +class FakeSocketModeHandler: + def __init__(self, app: FakeApp, app_token: str) -> None: + self.app = app + self.app_token = app_token + + def start(self) -> None: + return None + + def close(self) -> None: + return None + + +def _build_handler(monkeypatch): + from lazy_enter import slack_handler as slack_handler_module + + monkeypatch.setattr(slack_handler_module, "App", FakeApp) + monkeypatch.setattr( + slack_handler_module, "SocketModeHandler", FakeSocketModeHandler + ) + + config = Config() + config.allowed_user_id = "U1" + config.allowed_channel_id = "C1" + return slack_handler_module.SlackHandler(config) + + +def _capture_commands(handler) -> list[tuple[str, str, str]]: + called: list[tuple[str, str, str]] = [] + + def callback(action: str, target: str, channel: str) -> None: + called.append((action, target, channel)) + + handler.on_command(callback) + return called + + +def test_start_command_routes_target_from_text(monkeypatch) -> None: + handler = _build_handler(monkeypatch) + called = _capture_commands(handler) + acked: list[bool] = [] + + start_handler = handler.app.command_handlers["/start"] + start_handler( + lambda: acked.append(True), + {"user_id": "U1", "channel_id": "C1", "text": "codex"}, + ) + + assert acked == [True] + assert called == [("start", "codex", "C1")] + + +def test_start_command_normalizes_target(monkeypatch) -> None: + handler = _build_handler(monkeypatch) + called = _capture_commands(handler) + + start_handler = handler.app.command_handlers["/start"] + start_handler( + lambda: None, + {"user_id": "U1", "channel_id": "C1", "text": " Claude now "}, + ) + + assert called == [("start", "claude", "C1")] + + +def test_stop_command_routes_target_from_text(monkeypatch) -> None: + handler = _build_handler(monkeypatch) + called = _capture_commands(handler) + + stop_handler = handler.app.command_handlers["/stop"] + stop_handler( + lambda: None, + {"user_id": "U1", "channel_id": "C1", "text": "claude"}, + ) + + assert called == [("stop", "claude", "C1")] + + +def test_invalid_target_sends_warning_message(monkeypatch) -> None: + handler = _build_handler(monkeypatch) + called = _capture_commands(handler) + + start_handler = handler.app.command_handlers["/start"] + start_handler( + lambda: None, + {"user_id": "U1", "channel_id": "C1", "text": "gpt"}, + ) + + assert called == [] + assert handler.app.client.messages[-1]["channel"] == "C1" + assert "claude" in str(handler.app.client.messages[-1]["text"]) + assert "codex" in str(handler.app.client.messages[-1]["text"]) + + +def test_unauthorized_command_is_ignored(monkeypatch) -> None: + handler = _build_handler(monkeypatch) + called = _capture_commands(handler) + + start_handler = handler.app.command_handlers["/start"] + start_handler( + lambda: None, + {"user_id": "U2", "channel_id": "C1", "text": "codex"}, + ) + + assert called == [] + assert handler.app.client.messages == []