diff --git a/README.md b/README.md index ff2bae7..99df2a8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ reminders, saved records, briefings, and lightweight Gitea/status lookup. In Telegram groups, disable bot privacy with BotFather if you want the bot to receive normal non-command messages. +In `work` chats, normal messages are routed to the Codex work session by +default. The bridge only handles narrow operational intents directly, such as +usage checks, cancellation, or conversational status questions like "지금 뭐해?". +Messages sent while a work turn is still running are queued and forwarded as +follow-up instructions after the current turn completes. + On Synology/NAS Docker deployments, keep `CODEX_WRITE_SANDBOX` set to `danger-full-access`. The stricter `workspace-write` sandbox may fail with bubblewrap namespace errors inside the container. Personal assistant chats use diff --git a/app/bot.py b/app/bot.py index ad44ff6..bbd8390 100644 --- a/app/bot.py +++ b/app/bot.py @@ -113,6 +113,7 @@ class TelegramCodexBot: self.chat_clients: dict[int, PersistentAppServerRunner] = {} self.model_menus: dict[int, list[str]] = {} self.running_jobs: dict[int, RunningJob] = {} + self.pending_prompts: dict[int, list[str]] = {} self.scheduler_task: asyncio.Task | None = None def build_app(self) -> Application: @@ -284,14 +285,19 @@ class TelegramCodexBot: async def cancel(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return + await self._cancel_current_job(update) + + async def _cancel_current_job(self, update: Update) -> None: chat_id = update.effective_chat.id job = self.running_jobs.get(chat_id) if not job or job.task.done(): await self._reply(update, "현재 진행 중인 작업이 없습니다.") return + queued_count = len(self.pending_prompts.pop(chat_id, [])) job.task.cancel() await self._close_client_for_chat(chat_id) - await self._reply(update, f"작업 취소를 요청했습니다.\n{self._format_job_status(chat_id)}") + queue_note = f"\n대기 중이던 후속 지시 {queued_count}개도 비웠습니다." if queued_count else "" + await self._reply(update, f"작업 취소를 요청했습니다.{queue_note}\n{self._format_job_status(chat_id)}") async def usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): @@ -951,6 +957,12 @@ class TelegramCodexBot: if _looks_like_usage_question(text): await self._reply_usage(update) return + if _looks_like_cancel_request(text): + await self._cancel_current_job(update) + return + if _looks_like_status_question(text): + await self._reply(update, self._format_conversational_status(update.effective_chat.id)) + return await self._run_codex_from_update(update, text) async def _run_codex_from_update( @@ -962,14 +974,30 @@ class TelegramCodexBot: chat_id = update.effective_chat.id active_job = self.running_jobs.get(chat_id) if active_job and not active_job.task.done(): + self.pending_prompts.setdefault(chat_id, []).append(prompt) await self._reply( update, - "이미 작업이 진행 중입니다.\n" - f"{self._format_job_status(chat_id)}\n\n" - "끝난 뒤 새 요청을 보내거나 /status로 상태를 확인해 주세요.", + "지금 작업이 진행 중이라, 이 메시지는 다음 지시로 이어서 전달해둘게요.\n" + f"{self._format_conversational_status(chat_id)}\n\n" + f"대기 중인 후속 지시: {len(self.pending_prompts.get(chat_id, []))}개\n" + f"추가한 지시: {_preview_prompt(prompt)}", ) return + await self._start_codex_job( + bot=update.get_bot(), + chat_id=chat_id, + prompt=prompt, + sandbox=sandbox, + ) + + async def _start_codex_job( + self, + bot: Any, + chat_id: int, + prompt: str, + sandbox: str | None = None, + ) -> None: chat_mode = self._chat_mode(chat_id) selected_sandbox = sandbox or self._default_sandbox_for_chat(chat_id) auto_repo_path = self._auto_select_repo_for_prompt(chat_id, prompt) if chat_mode == "work" else None @@ -990,14 +1018,14 @@ class TelegramCodexBot: prompt_preview=preview, sandbox=selected_sandbox, ) - mode = f"{chat_mode}/{_sandbox_mode_label(selected_sandbox, self.config)}" - repo_note = f"\n자동 작업폴더: {auto_repo_path}" if auto_repo_path else "" - await self._reply( - update, - f"{mode}을 시작했습니다.\n" - f"요청: {preview}\n" - "완료되면 결과를 보내드릴게요. 진행 중에는 /status로 확인할 수 있습니다." - f"{repo_note}", + await self._send_chat( + bot, + chat_id, + self._format_start_message( + chat_mode=chat_mode, + preview=preview, + auto_repo_path=auto_repo_path, + ), ) async def _run_codex_job( @@ -1026,13 +1054,28 @@ class TelegramCodexBot: current = self.running_jobs.get(chat_id) if current and current.task is asyncio.current_task(): self.running_jobs.pop(chat_id, None) + await self._start_next_pending_job(bot, chat_id) + + async def _start_next_pending_job(self, bot: Any, chat_id: int) -> None: + queue = self.pending_prompts.get(chat_id) + if not queue: + return + next_prompt = queue.pop(0) + if not queue: + self.pending_prompts.pop(chat_id, None) + await self._send_chat(bot, chat_id, "대기 중이던 다음 지시를 이어서 진행할게요.") + await self._start_codex_job( + bot=bot, + chat_id=chat_id, + prompt=next_prompt, + sandbox=self._default_sandbox_for_chat(chat_id), + ) async def _job_heartbeat(self, bot: Any, chat_id: int) -> None: - await asyncio.sleep(60) + await asyncio.sleep(45 if self._chat_mode(chat_id) == "work" else 90) while True: - job_status = self._format_job_status(chat_id) - await self._send_chat(bot, chat_id, f"아직 작업 중입니다.\n{job_status}") - await asyncio.sleep(120) + await self._send_chat(bot, chat_id, self._format_progress_message(chat_id)) + await asyncio.sleep(90 if self._chat_mode(chat_id) == "work" else 180) async def _reply_result(self, update: Update, result: CodexResult, fallback_note: str = "") -> None: if result.ok: @@ -1303,6 +1346,55 @@ class TelegramCodexBot: f"요청={job.prompt_preview}" ) + def _format_conversational_status(self, chat_id: int) -> str: + job = self.running_jobs.get(chat_id) + if not job or job.task.done(): + if self._chat_mode(chat_id) == "work": + return "지금 진행 중인 작업은 없습니다. 작업 그룹에 메시지를 보내면 그대로 Codex 작업 세션으로 전달합니다." + return "지금 진행 중인 비서 작업은 없습니다." + elapsed = int(time.monotonic() - job.started_monotonic) + minutes, seconds = divmod(elapsed, 60) + mode = f"{self._chat_mode(chat_id)}/{_sandbox_mode_label(job.sandbox, self.config)}" + queued_count = len(self.pending_prompts.get(chat_id, [])) + queue_line = f"\n- 대기 중인 후속 지시: {queued_count}개" if queued_count else "" + return ( + "아직 작업 중입니다.\n" + f"- 모드: {mode}\n" + f"- 경과: {minutes}분 {seconds}초\n" + f"- 요청: {job.prompt_preview}" + f"{queue_line}" + ) + + def _format_start_message( + self, + chat_mode: str, + preview: str, + auto_repo_path: Path | None, + ) -> str: + repo_note = f"\n작업 폴더는 `{auto_repo_path}`로 잡았습니다." if auto_repo_path else "" + if chat_mode == "work": + return ( + "좋아요. 작업 세션에 바로 넘겼습니다.\n" + "먼저 필요한 파일과 현재 상태를 확인한 뒤 진행할게요." + f"{repo_note}\n\n" + f"받은 내용: {preview}" + ) + return ( + "알겠습니다. 비서 세션에서 확인해볼게요.\n" + f"받은 내용: {preview}" + ) + + def _format_progress_message(self, chat_id: int) -> str: + if self._chat_mode(chat_id) == "work": + return ( + "진행 중입니다. Codex 작업 세션이 아직 처리하고 있어요.\n" + f"{self._format_conversational_status(chat_id)}" + ) + return ( + "아직 확인 중입니다.\n" + f"{self._format_conversational_status(chat_id)}" + ) + def _with_bridge_context( self, user_prompt: str, @@ -1390,10 +1482,14 @@ class TelegramCodexBot: def _chat_mode_instructions(self, chat_id: int) -> str: if self._chat_mode(chat_id) == "work": return ( - "This is the user's work chat. You may perform repository work, edit files, " - "run builds/tests, and prepare commits when the user asks. Commit or push only " - "when the user explicitly asks for commit/push or when the request clearly " - "includes publishing." + "This is the user's work chat. Treat ordinary user messages as instructions " + "or follow-up instructions for the current Codex work session, not as requests " + "for the Telegram bridge server to interpret. You may perform repository work, " + "edit files, run builds/tests, and prepare commits when the user asks. Commit " + "or push only when the user explicitly asks for commit/push or when the request " + "clearly includes publishing. When reporting progress or completion, be concise " + "and include changed files, verification, commit/push status, and next action " + "when relevant." ) return ( "This is the user's personal assistant chat. Focus on schedules, reminders, saved " @@ -1510,6 +1606,77 @@ def _looks_like_usage_question(text: str) -> bool: return any(term in normalized for term in implicit_codex_terms) +def _looks_like_status_question(text: str) -> bool: + normalized = " ".join(text.casefold().split()) + exact = { + "지금 뭐해", + "지금 뭐해?", + "뭐하고 있어", + "뭐하고 있어?", + "뭐 하는 중", + "뭐 하는 중?", + "어디까지 했어", + "어디까지 했어?", + "진행상황", + "진행 상황", + "상태", + "status", + } + if normalized in exact: + return True + if _looks_like_work_instruction(normalized): + return False + if len(normalized) > 32: + return False + status_terms = ("진행상황", "진행 상황", "현재 상태", "작업 상태", "어디까지", "뭐 하는 중", "뭐하고") + return any(term in normalized for term in status_terms) + + +def _looks_like_cancel_request(text: str) -> bool: + normalized = " ".join(text.casefold().split()) + exact = { + "취소", + "취소해", + "멈춰", + "중지", + "중지해", + "그만", + "그만해", + "stop", + "cancel", + } + if normalized in exact: + return True + if _looks_like_work_instruction(normalized): + return False + if len(normalized) > 28: + return False + cancel_terms = ("작업 취소", "작업 중지", "멈춰줘", "취소해줘", "그만해줘", "stop it", "cancel it") + return any(term in normalized for term in cancel_terms) + + +def _looks_like_work_instruction(normalized: str) -> bool: + work_terms = ( + "고쳐", + "수정", + "구현", + "만들", + "추가", + "확인하고", + "빌드", + "테스트", + "생성", + "작업해", + "진행해", + "기능", + "파일", + "코드", + "커밋", + "푸시", + ) + return any(term in normalized for term in work_terms) + + def _format_codex_usage(payload: Any) -> str: if not isinstance(payload, dict): return "Codex 사용량 응답 형식을 해석하지 못했습니다."