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

@@ -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 Telegram groups, disable bot privacy with BotFather if you want the bot to
receive normal non-command messages. 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 On Synology/NAS Docker deployments, keep `CODEX_WRITE_SANDBOX` set to
`danger-full-access`. The stricter `workspace-write` sandbox may fail with `danger-full-access`. The stricter `workspace-write` sandbox may fail with
bubblewrap namespace errors inside the container. Personal assistant chats use bubblewrap namespace errors inside the container. Personal assistant chats use

View File

@@ -113,6 +113,7 @@ class TelegramCodexBot:
self.chat_clients: dict[int, PersistentAppServerRunner] = {} self.chat_clients: dict[int, PersistentAppServerRunner] = {}
self.model_menus: dict[int, list[str]] = {} self.model_menus: dict[int, list[str]] = {}
self.running_jobs: dict[int, RunningJob] = {} self.running_jobs: dict[int, RunningJob] = {}
self.pending_prompts: dict[int, list[str]] = {}
self.scheduler_task: asyncio.Task | None = None self.scheduler_task: asyncio.Task | None = None
def build_app(self) -> Application: def build_app(self) -> Application:
@@ -284,14 +285,19 @@ class TelegramCodexBot:
async def cancel(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def cancel(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await self._guard(update): if not await self._guard(update):
return return
await self._cancel_current_job(update)
async def _cancel_current_job(self, update: Update) -> None:
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
job = self.running_jobs.get(chat_id) job = self.running_jobs.get(chat_id)
if not job or job.task.done(): if not job or job.task.done():
await self._reply(update, "현재 진행 중인 작업이 없습니다.") await self._reply(update, "현재 진행 중인 작업이 없습니다.")
return return
queued_count = len(self.pending_prompts.pop(chat_id, []))
job.task.cancel() job.task.cancel()
await self._close_client_for_chat(chat_id) 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: async def usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await self._guard(update): if not await self._guard(update):
@@ -951,6 +957,12 @@ class TelegramCodexBot:
if _looks_like_usage_question(text): if _looks_like_usage_question(text):
await self._reply_usage(update) await self._reply_usage(update)
return 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) await self._run_codex_from_update(update, text)
async def _run_codex_from_update( async def _run_codex_from_update(
@@ -962,14 +974,30 @@ class TelegramCodexBot:
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
active_job = self.running_jobs.get(chat_id) active_job = self.running_jobs.get(chat_id)
if active_job and not active_job.task.done(): if active_job and not active_job.task.done():
self.pending_prompts.setdefault(chat_id, []).append(prompt)
await self._reply( await self._reply(
update, update,
"이미 작업이 진행 중입니다.\n" "지금 작업이 진행 중이라, 이 메시지는 다음 지시로 이어서 전달해둘게요.\n"
f"{self._format_job_status(chat_id)}\n\n" f"{self._format_conversational_status(chat_id)}\n\n"
"끝난 뒤 새 요청을 보내거나 /status로 상태를 확인해 주세요.", f"대기 중인 후속 지시: {len(self.pending_prompts.get(chat_id, []))}\n"
f"추가한 지시: {_preview_prompt(prompt)}",
) )
return 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) chat_mode = self._chat_mode(chat_id)
selected_sandbox = sandbox or self._default_sandbox_for_chat(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 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, prompt_preview=preview,
sandbox=selected_sandbox, sandbox=selected_sandbox,
) )
mode = f"{chat_mode}/{_sandbox_mode_label(selected_sandbox, self.config)}" await self._send_chat(
repo_note = f"\n자동 작업폴더: {auto_repo_path}" if auto_repo_path else "" bot,
await self._reply( chat_id,
update, self._format_start_message(
f"{mode}을 시작했습니다.\n" chat_mode=chat_mode,
f"요청: {preview}\n" preview=preview,
"완료되면 결과를 보내드릴게요. 진행 중에는 /status로 확인할 수 있습니다." auto_repo_path=auto_repo_path,
f"{repo_note}", ),
) )
async def _run_codex_job( async def _run_codex_job(
@@ -1026,13 +1054,28 @@ class TelegramCodexBot:
current = self.running_jobs.get(chat_id) current = self.running_jobs.get(chat_id)
if current and current.task is asyncio.current_task(): if current and current.task is asyncio.current_task():
self.running_jobs.pop(chat_id, None) 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: 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: while True:
job_status = self._format_job_status(chat_id) await self._send_chat(bot, chat_id, self._format_progress_message(chat_id))
await self._send_chat(bot, chat_id, f"아직 작업 중입니다.\n{job_status}") await asyncio.sleep(90 if self._chat_mode(chat_id) == "work" else 180)
await asyncio.sleep(120)
async def _reply_result(self, update: Update, result: CodexResult, fallback_note: str = "") -> None: async def _reply_result(self, update: Update, result: CodexResult, fallback_note: str = "") -> None:
if result.ok: if result.ok:
@@ -1303,6 +1346,55 @@ class TelegramCodexBot:
f"요청={job.prompt_preview}" 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( def _with_bridge_context(
self, self,
user_prompt: str, user_prompt: str,
@@ -1390,10 +1482,14 @@ class TelegramCodexBot:
def _chat_mode_instructions(self, chat_id: int) -> str: def _chat_mode_instructions(self, chat_id: int) -> str:
if self._chat_mode(chat_id) == "work": if self._chat_mode(chat_id) == "work":
return ( return (
"This is the user's work chat. You may perform repository work, edit files, " "This is the user's work chat. Treat ordinary user messages as instructions "
"run builds/tests, and prepare commits when the user asks. Commit or push only " "or follow-up instructions for the current Codex work session, not as requests "
"when the user explicitly asks for commit/push or when the request clearly " "for the Telegram bridge server to interpret. You may perform repository work, "
"includes publishing." "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 ( return (
"This is the user's personal assistant chat. Focus on schedules, reminders, saved " "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) 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: def _format_codex_usage(payload: Any) -> str:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return "Codex 사용량 응답 형식을 해석하지 못했습니다." return "Codex 사용량 응답 형식을 해석하지 못했습니다."