Route work chat messages to Codex session

This commit is contained in:
2026-06-10 12:02:52 +09:00
parent 5e3a439cd8
commit 2f8268b44b
2 changed files with 193 additions and 20 deletions

View File

@@ -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 사용량 응답 형식을 해석하지 못했습니다."