4 Commits

5 changed files with 227 additions and 52 deletions

View File

@@ -8,11 +8,11 @@
1. 로컬에서 Claude 또는 Codex를 tmux 세션으로 미리 실행 1. 로컬에서 Claude 또는 Codex를 tmux 세션으로 미리 실행
- Claude: `tmux new -s claude claude` - Claude: `tmux new -s claude claude`
- Codex: `tmux new -s codex codex` - Codex: `tmux new -s codex codex`
2. Slack에서 `/start-claude` 또는 `/start-codex` 실행 2. Slack에서 `/start claude` 또는 `/start codex` 실행
3. 브릿지가 기존 tmux 세션에 attach 3. 브릿지가 기존 tmux 세션에 attach
4. Slack 채널 메시지가 CLI 입력으로 전달됨 (기본: 엔터 미포함) 4. Slack 채널 메시지가 CLI 입력으로 전달됨 (기본: 엔터 미포함)
5. CLI 출력이 Slack으로 다시 전송됨 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` - `SLACK_ALLOWED_CHANNEL_ID`
선택 환경 변수: 선택 환경 변수:
- `TMUX_SESSION_NAME` (기본: `claude`, `/start-claude` 대상) - `TMUX_SESSION_NAME` (기본: `claude`, `/start claude` 대상)
- `CODEX_TMUX_SESSION_NAME` (기본: `codex`, `/start-codex` 대상) - `CODEX_TMUX_SESSION_NAME` (기본: `codex`, `/start codex` 대상)
- `PTY_READ_TIMEOUT` (기본: `5`) - `PTY_READ_TIMEOUT` (기본: `5`)
- `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`) - `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`)
- `OUTPUT_SETTLE_SECONDS` (기본: `4.0`, 출력이 잠잠해진 뒤 전송 대기 시간) - `OUTPUT_SETTLE_SECONDS` (기본: `4.0`, 출력이 잠잠해진 뒤 전송 대기 시간)
@@ -55,10 +55,8 @@ cp .env.example .env
- Socket Mode 활성화 - Socket Mode 활성화
- Bot Token Scopes에 `chat:write`, `commands` 추가 - Bot Token Scopes에 `chat:write`, `commands` 추가
- Slash Commands 생성: - Slash Commands 생성:
- `/start-claude` - `/start`
- `/stop-claude` - `/stop`
- `/start-codex`
- `/stop-codex`
- 앱을 워크스페이스에 설치 후 토큰을 `.env`에 반영 - 앱을 워크스페이스에 설치 후 토큰을 `.env`에 반영
## 실행 ## 실행
@@ -80,10 +78,10 @@ python -m lazy_enter
``` ```
실행 후 Slack의 허용된 채널에서: 실행 후 Slack의 허용된 채널에서:
- `/start-claude`, `/start-codex`: 기존 세션에 연결 - `/start claude`, `/start codex`: 기존 세션에 연결
- 일반 메시지 전송: 현재 연결된 CLI(Claude/Codex)로 입력만 전달 (엔터 미포함) - 일반 메시지 전송: 현재 연결된 CLI(Claude/Codex)로 입력만 전달 (엔터 미포함)
- `!e`, `!enter` 전송: 엔터 키만 전달 (현재 프롬프트 제출) - `!e`, `!enter` 전송: 엔터 키만 전달 (현재 프롬프트 제출)
- `/stop-claude`, `/stop-codex`: 브릿지 연결 해제 (세션 유지) - `/stop claude`, `/stop codex`: 브릿지 연결 해제 (세션 유지)
## 테스트 및 품질 점검 ## 테스트 및 품질 점검

View File

@@ -96,11 +96,10 @@ class Bridge:
def _handle_command(self, action: str, target: str, channel: str) -> None: def _handle_command(self, action: str, target: str, channel: str) -> None:
"""슬래시 커맨드를 처리한다.""" """슬래시 커맨드를 처리한다."""
if action == "start":
if target not in {"claude", "codex"}: if target not in {"claude", "codex"}:
self.slack.send_message(channel, ":warning: 지원하지 않는 대상입니다.") self.slack.send_message(channel, ":warning: 지원하지 않는 대상입니다.")
return return
if action == "start":
self._start_session(channel, target) self._start_session(channel, target)
elif action == "stop": elif action == "stop":
self._stop_session(channel) self._stop_session(channel)

View File

@@ -58,8 +58,31 @@ class SlackHandler:
if self._on_message_callback: if self._on_message_callback:
self._on_message_callback(text, channel_id) self._on_message_callback(text, channel_id)
@self.app.command("/start-claude") @self.app.command("/start")
def handle_start_claude(ack: Callable[..., None], body: dict) -> None: 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 target is None:
self.send_message(
channel_id,
(
":warning: 대상은 `claude` 또는 `codex`여야 합니다. "
"예: `/start codex`"
),
)
return
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() ack()
user_id = body.get("user_id", "") user_id = body.get("user_id", "")
channel_id = body.get("channel_id", "") channel_id = body.get("channel_id", "")
@@ -68,43 +91,15 @@ class SlackHandler:
return return
if self._on_command_callback: if self._on_command_callback:
self._on_command_callback("start", "claude", channel_id) self._on_command_callback("stop", "", channel_id)
@self.app.command("/stop-claude") @staticmethod
def handle_stop_claude(ack: Callable[..., None], body: dict) -> None: def _parse_target(text: str) -> str | None:
ack() """슬래시 커맨드 인자에서 대상을 파싱한다."""
user_id = body.get("user_id", "") first_token = text.strip().split(maxsplit=1)[0].lower() if text.strip() else ""
channel_id = body.get("channel_id", "") if first_token in {"claude", "codex"}:
return first_token
if not self._is_authorized(user_id, channel_id): return None
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):
return
if self._on_command_callback:
self._on_command_callback("start", "codex", 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)
def on_message(self, callback: Callable[[str, str], None]) -> None: def on_message(self, callback: Callable[[str, str], None]) -> None:
"""메시지 수신 콜백을 등록한다.""" """메시지 수신 콜백을 등록한다."""

View File

@@ -132,6 +132,21 @@ def test_unknown_target_is_rejected(monkeypatch) -> None:
) )
def test_stop_command_ignores_empty_target(monkeypatch) -> None:
FakePtyManager.instances.clear()
bridge = _make_bridge(monkeypatch)
bridge._handle_command("start", "codex", "C1")
bridge._handle_command("stop", "", "C1")
assert bridge.pty is None
assert bridge._active_target is None
assert bridge.slack.sent_messages[-1] == (
"C1",
":electric_plug: 세션 연결이 해제되었습니다.",
)
def test_handle_message_resets_last_sent_output_after_input(monkeypatch) -> None: def test_handle_message_resets_last_sent_output_after_input(monkeypatch) -> None:
FakePtyManager.instances.clear() FakePtyManager.instances.clear()
bridge = _make_bridge(monkeypatch) bridge = _make_bridge(monkeypatch)

168
tests/test_slack_handler.py Normal file
View File

@@ -0,0 +1,168 @@
"""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_ignores_target_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", "", "C1")]
def test_stop_command_without_target_routes_immediately(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": ""},
)
assert called == [("stop", "", "C1")]
assert handler.app.client.messages == []
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 == []