Run Telegram Codex jobs in background
This commit is contained in:
151
app/bot.py
151
app/bot.py
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ HELP_TEXT = """Telegram-Codex 연결 브리지
|
|||||||
/repo <경로> - /workspaces 아래 작업 폴더 설정
|
/repo <경로> - /workspaces 아래 작업 폴더 설정
|
||||||
/ask <내용> - 내용을 Codex로 전달
|
/ask <내용> - 내용을 Codex로 전달
|
||||||
/work <내용> - 파일 수정, 커밋, 푸시 같은 쓰기 작업 실행
|
/work <내용> - 파일 수정, 커밋, 푸시 같은 쓰기 작업 실행
|
||||||
|
/cancel - 현재 진행 중인 작업 취소
|
||||||
/new - 이 채팅의 Codex 세션 초기화
|
/new - 이 채팅의 Codex 세션 초기화
|
||||||
/status - 브리지 상태 확인
|
/status - 브리지 상태 확인
|
||||||
/settings - 모델, 추론강도, 속도 설정 확인
|
/settings - 모델, 추론강도, 속도 설정 확인
|
||||||
@@ -82,6 +85,14 @@ RECORD_CATEGORY_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunningJob:
|
||||||
|
task: asyncio.Task
|
||||||
|
started_monotonic: float
|
||||||
|
prompt_preview: str
|
||||||
|
sandbox: str
|
||||||
|
|
||||||
|
|
||||||
class TelegramCodexBot:
|
class TelegramCodexBot:
|
||||||
def __init__(self, config: Config) -> None:
|
def __init__(self, config: Config) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -100,6 +111,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.scheduler_task: asyncio.Task | None = None
|
self.scheduler_task: asyncio.Task | None = None
|
||||||
|
|
||||||
def build_app(self) -> Application:
|
def build_app(self) -> Application:
|
||||||
@@ -117,6 +129,7 @@ class TelegramCodexBot:
|
|||||||
app.add_handler(CommandHandler("ask", self.ask))
|
app.add_handler(CommandHandler("ask", self.ask))
|
||||||
app.add_handler(CommandHandler("work", self.work))
|
app.add_handler(CommandHandler("work", self.work))
|
||||||
app.add_handler(CommandHandler("write", self.work))
|
app.add_handler(CommandHandler("write", self.work))
|
||||||
|
app.add_handler(CommandHandler("cancel", self.cancel))
|
||||||
app.add_handler(CommandHandler("new", self.new))
|
app.add_handler(CommandHandler("new", self.new))
|
||||||
app.add_handler(CommandHandler("status", self.status))
|
app.add_handler(CommandHandler("status", self.status))
|
||||||
app.add_handler(CommandHandler("settings", self.settings))
|
app.add_handler(CommandHandler("settings", self.settings))
|
||||||
@@ -145,8 +158,11 @@ class TelegramCodexBot:
|
|||||||
async def post_shutdown(self, app: Application) -> None:
|
async def post_shutdown(self, app: Application) -> None:
|
||||||
if self.scheduler_task:
|
if self.scheduler_task:
|
||||||
self.scheduler_task.cancel()
|
self.scheduler_task.cancel()
|
||||||
|
for job in self.running_jobs.values():
|
||||||
|
job.task.cancel()
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*(client.close() for client in self.chat_clients.values()),
|
*(client.close() for client in self.chat_clients.values()),
|
||||||
|
*(job.task for job in self.running_jobs.values()),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,10 +269,26 @@ class TelegramCodexBot:
|
|||||||
if not await self._guard(update):
|
if not await self._guard(update):
|
||||||
return
|
return
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
|
job = self.running_jobs.get(chat_id)
|
||||||
|
if job and not job.task.done():
|
||||||
|
await self._reply(update, "작업이 진행 중입니다. 먼저 /cancel로 취소하거나 작업 완료 후 /new를 사용해 주세요.")
|
||||||
|
return
|
||||||
self.state.clear_session(chat_id)
|
self.state.clear_session(chat_id)
|
||||||
await self._close_client_for_chat(chat_id)
|
await self._close_client_for_chat(chat_id)
|
||||||
await self._reply(update, "이 Telegram 채팅의 Codex 세션을 초기화했습니다.")
|
await self._reply(update, "이 Telegram 채팅의 Codex 세션을 초기화했습니다.")
|
||||||
|
|
||||||
|
async def cancel(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if not await self._guard(update):
|
||||||
|
return
|
||||||
|
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
|
||||||
|
job.task.cancel()
|
||||||
|
await self._close_client_for_chat(chat_id)
|
||||||
|
await self._reply(update, f"작업 취소를 요청했습니다.\n{self._format_job_status(chat_id)}")
|
||||||
|
|
||||||
async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if not await self._guard(update):
|
if not await self._guard(update):
|
||||||
return
|
return
|
||||||
@@ -267,6 +299,7 @@ class TelegramCodexBot:
|
|||||||
persistent_status = "연결 안 됨"
|
persistent_status = "연결 안 됨"
|
||||||
if client and client.is_connected:
|
if client and client.is_connected:
|
||||||
persistent_status = f"연결됨, 유휴 {int(client.idle_seconds())}초"
|
persistent_status = f"연결됨, 유휴 {int(client.idle_seconds())}초"
|
||||||
|
job_status = self._format_job_status(chat_id)
|
||||||
await self._reply(
|
await self._reply(
|
||||||
update,
|
update,
|
||||||
"\n".join(
|
"\n".join(
|
||||||
@@ -278,6 +311,7 @@ class TelegramCodexBot:
|
|||||||
f"persistent_ws={persistent_status}",
|
f"persistent_ws={persistent_status}",
|
||||||
f"persistent_ws_유휴제한={self.config.persistent_ws_idle_seconds}초",
|
f"persistent_ws_유휴제한={self.config.persistent_ws_idle_seconds}초",
|
||||||
f"스케줄={len(schedules)}개",
|
f"스케줄={len(schedules)}개",
|
||||||
|
job_status,
|
||||||
self._format_runtime_settings(),
|
self._format_runtime_settings(),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -900,15 +934,73 @@ class TelegramCodexBot:
|
|||||||
sandbox: str | None = None,
|
sandbox: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
if update.effective_chat:
|
active_job = self.running_jobs.get(chat_id)
|
||||||
await update.effective_chat.send_action(ChatAction.TYPING)
|
if active_job and not active_job.task.done():
|
||||||
result, fallback_note = await self._run_codex_for_chat(
|
await self._reply(
|
||||||
chat_id=chat_id,
|
update,
|
||||||
prompt=prompt,
|
"이미 작업이 진행 중입니다.\n"
|
||||||
sandbox=sandbox or self.config.readonly_sandbox,
|
f"{self._format_job_status(chat_id)}\n\n"
|
||||||
update_chat_state=True,
|
"끝난 뒤 새 요청을 보내거나 /status로 상태를 확인해 주세요.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_sandbox = sandbox or self.config.readonly_sandbox
|
||||||
|
preview = _preview_prompt(prompt)
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._run_codex_job(
|
||||||
|
bot=update.get_bot(),
|
||||||
|
chat_id=chat_id,
|
||||||
|
prompt=prompt,
|
||||||
|
sandbox=selected_sandbox,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await self._reply_result(update, result, fallback_note)
|
self.running_jobs[chat_id] = RunningJob(
|
||||||
|
task=task,
|
||||||
|
started_monotonic=time.monotonic(),
|
||||||
|
prompt_preview=preview,
|
||||||
|
sandbox=selected_sandbox,
|
||||||
|
)
|
||||||
|
mode = "쓰기 작업" if selected_sandbox != self.config.readonly_sandbox else "조회 작업"
|
||||||
|
await self._reply(
|
||||||
|
update,
|
||||||
|
f"{mode}을 시작했습니다.\n"
|
||||||
|
f"요청: {preview}\n"
|
||||||
|
"완료되면 결과를 보내드릴게요. 진행 중에는 /status로 확인할 수 있습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_codex_job(
|
||||||
|
self,
|
||||||
|
bot: Any,
|
||||||
|
chat_id: int,
|
||||||
|
prompt: str,
|
||||||
|
sandbox: str,
|
||||||
|
) -> None:
|
||||||
|
heartbeat = asyncio.create_task(self._job_heartbeat(bot, chat_id))
|
||||||
|
try:
|
||||||
|
result, fallback_note = await self._run_codex_for_chat(
|
||||||
|
chat_id=chat_id,
|
||||||
|
prompt=prompt,
|
||||||
|
sandbox=sandbox,
|
||||||
|
update_chat_state=True,
|
||||||
|
)
|
||||||
|
await self._send_result(bot, chat_id, result, fallback_note)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await self._send_chat(bot, chat_id, f"작업 중 오류가 발생했습니다:\n{exc}")
|
||||||
|
finally:
|
||||||
|
heartbeat.cancel()
|
||||||
|
await asyncio.gather(heartbeat, return_exceptions=True)
|
||||||
|
current = self.running_jobs.get(chat_id)
|
||||||
|
if current and current.task is asyncio.current_task():
|
||||||
|
self.running_jobs.pop(chat_id, None)
|
||||||
|
|
||||||
|
async def _job_heartbeat(self, bot: Any, chat_id: int) -> None:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
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)
|
||||||
|
|
||||||
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:
|
||||||
@@ -924,6 +1016,28 @@ class TelegramCodexBot:
|
|||||||
note_text = f"{fallback_note}\n\n" if fallback_note else ""
|
note_text = f"{fallback_note}\n\n" if fallback_note else ""
|
||||||
await self._reply(update, f"실패(returncode={result.returncode})\n\n{note_text}{result.final_message}{stderr_text}")
|
await self._reply(update, f"실패(returncode={result.returncode})\n\n{note_text}{result.final_message}{stderr_text}")
|
||||||
|
|
||||||
|
async def _send_result(self, bot: Any, chat_id: int, result: CodexResult, fallback_note: str = "") -> None:
|
||||||
|
if result.ok:
|
||||||
|
text = result.final_message
|
||||||
|
if result.stderr and "runtime setting overrides" in result.stderr:
|
||||||
|
text = f"브리지 참고: {result.stderr}\n\n{text}"
|
||||||
|
if fallback_note:
|
||||||
|
text = f"{fallback_note}\n\n{text}"
|
||||||
|
await self._send_chat(bot, chat_id, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
stderr_text = f"\n\nstderr:\n{result.stderr[-1000:]}" if result.stderr else ""
|
||||||
|
note_text = f"{fallback_note}\n\n" if fallback_note else ""
|
||||||
|
await self._send_chat(
|
||||||
|
bot,
|
||||||
|
chat_id,
|
||||||
|
f"실패(returncode={result.returncode})\n\n{note_text}{result.final_message}{stderr_text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_chat(self, bot: Any, chat_id: int, text: str) -> None:
|
||||||
|
for chunk in _split_message(text):
|
||||||
|
await bot.send_message(chat_id=chat_id, text=chunk)
|
||||||
|
|
||||||
async def _run_codex_for_chat(
|
async def _run_codex_for_chat(
|
||||||
self,
|
self,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
@@ -1143,6 +1257,20 @@ class TelegramCodexBot:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _format_job_status(self, chat_id: int) -> str:
|
||||||
|
job = self.running_jobs.get(chat_id)
|
||||||
|
if not job or job.task.done():
|
||||||
|
return "현재작업=없음"
|
||||||
|
elapsed = int(time.monotonic() - job.started_monotonic)
|
||||||
|
minutes, seconds = divmod(elapsed, 60)
|
||||||
|
mode = "write" if job.sandbox != self.config.readonly_sandbox else "read-only"
|
||||||
|
return (
|
||||||
|
"현재작업=진행 중\n"
|
||||||
|
f"작업모드={mode}\n"
|
||||||
|
f"경과={minutes}분 {seconds}초\n"
|
||||||
|
f"요청={job.prompt_preview}"
|
||||||
|
)
|
||||||
|
|
||||||
def _with_bridge_context(
|
def _with_bridge_context(
|
||||||
self,
|
self,
|
||||||
user_prompt: str,
|
user_prompt: str,
|
||||||
@@ -1253,6 +1381,13 @@ def _args_text(context: ContextTypes.DEFAULT_TYPE) -> str:
|
|||||||
return " ".join(context.args).strip()
|
return " ".join(context.args).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_prompt(prompt: str, limit: int = 120) -> str:
|
||||||
|
preview = " ".join(prompt.split())
|
||||||
|
if len(preview) <= limit:
|
||||||
|
return preview
|
||||||
|
return preview[: limit - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
def _checked_label(label: str, checked: bool) -> str:
|
def _checked_label(label: str, checked: bool) -> str:
|
||||||
return f"✓ {label}" if checked else label
|
return f"✓ {label}" if checked else label
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user