Route work chat messages to Codex session
This commit is contained in:
@@ -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
|
||||
|
||||
207
app/bot.py
207
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 사용량 응답 형식을 해석하지 못했습니다."
|
||||
|
||||
Reference in New Issue
Block a user