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
|
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
|
||||||
|
|||||||
207
app/bot.py
207
app/bot.py
@@ -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 사용량 응답 형식을 해석하지 못했습니다."
|
||||||
|
|||||||
Reference in New Issue
Block a user