from __future__ import annotations import asyncio import json import re from pathlib import Path from typing import Any from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ChatAction from telegram.ext import ( Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters, ) from app.app_server_runner import AppServerRunner, PersistentAppServerRunner from app.codex_runner import CodexResult, CodexRunner from app.config import Config from app.state import ChatState, StateStore, format_personal_record, format_schedule HELP_TEXT = """Telegram-Codex 연결 브리지 일반 메시지는 그대로 Codex app-server로 전달됩니다. 명령어: /whoami - 내 Telegram user_id/chat_id 확인 /repo <경로> - /workspaces 아래 작업 폴더 설정 /ask <내용> - 내용을 Codex로 전달 /new - 이 채팅의 Codex 세션 초기화 /status - 브리지 상태 확인 /settings - 모델, 추론강도, 속도 설정 확인 /set_model - 모델 변경 /set_effort - 추론강도 변경 /set_speed - 속도 모드 변경 /fast - 고속 모드 바로 변경/확인 /models - 모델 선택 버튼 열기 /schedules - 등록된 스케줄 목록/삭제 버튼 열기 /records - 개인 기록 목록/삭제 버튼 열기 /todos - 할 일 목록 열기 /purchases - 구매 후보 목록 열기 /birthdays - 생일 목록 열기 /sessions - app-server 세션 목록 확인 /sessions archived - 보관된 세션 목록 확인 /use_session - 기존 세션으로 전환 /archive_session [thread_id] - 세션 보관 /unarchive_session - 보관된 세션 복원 /help - 도움말 보기 """ EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"} RESET_WORDS = {"default", "reset", "unset", "기본", "초기화"} MODEL_ALIASES = { "spark": "gpt-5.3-codex-spark", "스파크": "gpt-5.3-codex-spark", "mini": "gpt-5.4-mini", "미니": "gpt-5.4-mini", } SPEED_ALIASES = { "standard": "standard", "normal": "standard", "off": "standard", "보통": "standard", "기본": "standard", "fast": "fast", "on": "fast", "고속": "fast", } RECORD_CATEGORY_LABELS = { "work_log": "업무이력", "todo": "할 일", "purchase": "구매", "birthday": "생일", "contact": "연락처", "note": "메모", "preference": "취향/설정", } class TelegramCodexBot: def __init__(self, config: Config) -> None: self.config = config self.state = StateStore(config.state_path) self.exec_runner = CodexRunner( codex_bin=config.codex_bin, timeout_seconds=config.codex_timeout_seconds, resume_json_flag_style=config.resume_json_flag_style, ) self.management_runner = AppServerRunner( url=config.app_server_url, timeout_seconds=config.codex_timeout_seconds, model=config.app_server_model, effort=config.app_server_reasoning_effort, service_tier=_service_tier_for_speed(config.app_server_speed_mode), ) self.chat_clients: dict[int, PersistentAppServerRunner] = {} self.model_menus: dict[int, list[str]] = {} self.scheduler_task: asyncio.Task | None = None def build_app(self) -> Application: app = ( Application.builder() .token(self.config.telegram_bot_token) .post_init(self.post_init) .post_shutdown(self.post_shutdown) .build() ) app.add_handler(CommandHandler("start", self.start)) app.add_handler(CommandHandler("help", self.start)) app.add_handler(CommandHandler("whoami", self.whoami)) app.add_handler(CommandHandler("repo", self.repo)) app.add_handler(CommandHandler("ask", self.ask)) app.add_handler(CommandHandler("new", self.new)) app.add_handler(CommandHandler("status", self.status)) app.add_handler(CommandHandler("settings", self.settings)) app.add_handler(CommandHandler("set_model", self.set_model)) app.add_handler(CommandHandler("set_effort", self.set_effort)) app.add_handler(CommandHandler("set_speed", self.set_speed)) app.add_handler(CommandHandler("fast", self.fast)) app.add_handler(CommandHandler("models", self.models)) app.add_handler(CommandHandler("schedules", self.schedules)) app.add_handler(CommandHandler("records", self.records)) app.add_handler(CommandHandler("todos", self.todos)) app.add_handler(CommandHandler("purchases", self.purchases)) app.add_handler(CommandHandler("birthdays", self.birthdays)) app.add_handler(CommandHandler("sessions", self.sessions)) app.add_handler(CommandHandler("use_session", self.use_session)) app.add_handler(CommandHandler("archive_session", self.archive_session)) app.add_handler(CommandHandler("unarchive_session", self.unarchive_session)) app.add_handler(CallbackQueryHandler(self.callback)) if self.config.allow_plain_text: app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.plain_text)) return app async def post_init(self, app: Application) -> None: self.scheduler_task = app.create_task(self.schedule_loop(app)) async def post_shutdown(self, app: Application) -> None: if self.scheduler_task: self.scheduler_task.cancel() await asyncio.gather( *(client.close() for client in self.chat_clients.values()), return_exceptions=True, ) def _is_allowed(self, update: Update) -> bool: if not self.config.allowed_user_ids: return False user = update.effective_user return bool(user and user.id in self.config.allowed_user_ids) async def _guard(self, update: Update) -> bool: if self._is_allowed(update): return True await self._reply(update, "허용되지 않은 Telegram 사용자입니다. /whoami로 user_id를 확인해 주세요.") return False async def _reply( self, update: Update, text: str, reply_markup: InlineKeyboardMarkup | None = None, ) -> None: if not update.effective_message: return chunks = _split_message(text) for index, chunk in enumerate(chunks): await update.effective_message.reply_text( chunk, reply_markup=reply_markup if index == len(chunks) - 1 else None, ) async def _reply_or_edit( self, update: Update, text: str, reply_markup: InlineKeyboardMarkup | None = None, ) -> None: query = update.callback_query if query and query.message: try: await query.edit_message_text(text, reply_markup=reply_markup) return except Exception: await query.message.reply_text(text, reply_markup=reply_markup) return if update.effective_message: await update.effective_message.reply_text(text, reply_markup=reply_markup) async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await self._reply_or_edit(update, HELP_TEXT, self._main_keyboard()) async def whoami(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.effective_user chat = update.effective_chat await self._reply( update, f"user_id={user.id if user else 'unknown'}\nchat_id={chat.id if chat else 'unknown'}", ) async def repo(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return if not context.args: await self._reply(update, "사용법: /repo <작업폴더 경로>") return requested = " ".join(context.args).strip() try: repo_path = self._resolve_workspace_path(requested) except ValueError as exc: await self._reply(update, str(exc)) return if not repo_path.exists(): await self._reply(update, f"경로가 존재하지 않습니다: {repo_path}") return self.state.set_repo(update.effective_chat.id, repo_path) await self._close_client_for_chat(update.effective_chat.id) await self._reply(update, f"작업 폴더를 설정했습니다:\n{repo_path}\nCodex 세션을 초기화했습니다.") async def ask(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return prompt = _args_text(context) if not prompt: await self._reply(update, "사용법: /ask <요청 내용>") return await self._run_codex_from_update(update, prompt) async def new(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return chat_id = update.effective_chat.id self.state.clear_session(chat_id) await self._close_client_for_chat(chat_id) await self._reply(update, "이 Telegram 채팅의 Codex 세션을 초기화했습니다.") async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return chat_id = update.effective_chat.id chat_state = self.state.get_chat(chat_id) client = self.chat_clients.get(chat_id) schedules = self.state.list_schedules(chat_id=chat_id) persistent_status = "연결 안 됨" if client and client.is_connected: persistent_status = f"연결됨, 유휴 {int(client.idle_seconds())}초" await self._reply( update, "\n".join( [ f"작업폴더={chat_state.repo_path or '(설정 안 됨)'}", f"백엔드={chat_state.session_backend or '(새 세션)'}", f"세션ID={chat_state.session_id or '(새 세션)'}", f"선호백엔드={self.config.codex_backend}", f"persistent_ws={persistent_status}", f"persistent_ws_유휴제한={self.config.persistent_ws_idle_seconds}초", f"스케줄={len(schedules)}개", self._format_runtime_settings(), ] ), self._main_keyboard(), ) async def settings(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return await self._reply_or_edit(update, self._format_runtime_settings(), self._settings_keyboard()) async def set_model(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return raw = _args_text(context) if not raw: await self._reply(update, "사용법: /set_model ") return normalized = raw.strip() lowered = normalized.lower() if lowered in RESET_WORDS: self.state.update_runtime_settings({"model": None}) action = "모델을 환경 기본값으로 되돌렸습니다." else: model = await self._resolve_model_input(lowered, normalized) self.state.update_runtime_settings({"model": model}) action = f"모델을 {model}(으)로 설정했습니다." await self._close_all_clients() await self._reply(update, f"{action}\n\n{self._format_runtime_settings()}") async def set_effort(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return raw = _args_text(context).strip().lower() if not raw: await self._reply(update, "사용법: /set_effort ") return if raw in RESET_WORDS: self.state.update_runtime_settings({"effort": None}) action = "추론강도를 환경 기본값으로 되돌렸습니다." elif raw in EFFORT_VALUES: self.state.update_runtime_settings({"effort": raw}) action = f"추론강도를 {raw}(으)로 설정했습니다." else: await self._reply(update, "사용 가능한 값: minimal, low, medium, high, xhigh, default") return await self._close_all_clients() await self._reply(update, f"{action}\n\n{self._format_runtime_settings()}") async def set_speed(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return raw = _args_text(context).strip().lower() if not raw: await self._reply(update, "사용법: /set_speed ") return if raw in RESET_WORDS: self.state.update_runtime_settings({"speed_mode": None}) action = "속도 모드를 환경 기본값으로 되돌렸습니다." else: mode = SPEED_ALIASES.get(raw) if not mode: await self._reply(update, "사용 가능한 값: standard, fast, 보통, 고속, default") return self.state.update_runtime_settings({"speed_mode": mode}) action = f"속도 모드를 {_display_speed(mode)}(으)로 설정했습니다." await self._close_all_clients() await self._reply(update, f"{action}\n\n{self._format_runtime_settings()}") async def fast(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return raw = _args_text(context).strip().lower() if not raw or raw == "status": await self._reply(update, self._format_runtime_settings()) return if raw not in {"on", "off"}: await self._reply(update, "사용법: /fast ") return mode = "fast" if raw == "on" else "standard" self.state.update_runtime_settings({"speed_mode": mode}) await self._close_all_clients() await self._reply(update, f"속도 모드를 {_display_speed(mode)}(으)로 설정했습니다.\n\n{self._format_runtime_settings()}") async def models(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return await self._show_model_menu(update) async def schedules(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return await self._show_schedule_list(update) async def records(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return arg = context.args[0] if context.args else None category = arg if arg in RECORD_CATEGORY_LABELS else None query = " ".join(context.args).strip() if arg and category is None else None await self._show_record_list(update, category=category, query=query) async def todos(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return await self._show_record_list(update, category="todo", status="active") async def purchases(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return await self._show_record_list(update, category="purchase", status="active") async def birthdays(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return await self._show_record_list(update, category="birthday") async def sessions(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return archived = bool(context.args and context.args[0].lower() in {"archived", "archive"}) try: result = await self.management_runner.list_threads(limit=12, archived=archived) except Exception as exc: await self._reply(update, f"세션 목록을 가져오지 못했습니다: {exc}") return text, keyboard = _format_thread_list(result, archived=archived) await self._reply_or_edit(update, text, keyboard) async def use_session(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return thread_id = _args_text(context) if not thread_id: await self._reply(update, "사용법: /use_session ") return try: resumed_id = await self._use_session_for_chat(update.effective_chat.id, thread_id) except Exception as exc: await self._reply(update, f"세션을 전환하지 못했습니다: {exc}") return await self._reply(update, f"Codex 세션을 전환했습니다.\n세션ID={resumed_id}") async def callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query if not query: return await query.answer() if not await self._guard(update): return data = query.data or "" chat_id = update.effective_chat.id try: if data == "sessions": result = await self.management_runner.list_threads(limit=12, archived=False) text, keyboard = _format_thread_list(result, archived=False) await self._reply_or_edit(update, text, keyboard) return if data == "sessions_archived": result = await self.management_runner.list_threads(limit=12, archived=True) text, keyboard = _format_thread_list(result, archived=True) await self._reply_or_edit(update, text, keyboard) return if data == "settings": await self._reply_or_edit(update, self._format_runtime_settings(), self._settings_keyboard()) return if data == "settings_models": await self._show_model_menu(update) return if data == "settings_effort": await self._show_effort_menu(update) return if data == "schedules": await self._show_schedule_list(update) return if data == "records": await self._show_record_list(update) return if data.startswith("records_cat:"): await self._show_record_list(update, category=data.split(":", 1)[1]) return if data.startswith("record_done:"): await self._mark_record_done_from_button(update, data.split(":", 1)[1]) return if data.startswith("record_delete_confirm:"): await self._confirm_record_delete(update, data.split(":", 1)[1]) return if data.startswith("record_delete:"): await self._delete_record_from_button(update, data.split(":", 1)[1]) return if data.startswith("schedule_delete_confirm:"): await self._confirm_schedule_delete(update, data.split(":", 1)[1]) return if data.startswith("schedule_delete:"): await self._delete_schedule_from_button(update, data.split(":", 1)[1]) return if data.startswith("set_speed:"): await self._set_speed_mode_from_button(update, data.split(":", 1)[1]) return if data.startswith("set_effort:"): await self._set_effort_from_button(update, data.split(":", 1)[1]) return if data == "model_default": self.state.update_runtime_settings({"model": None}) await self._close_all_clients() await self._reply_or_edit(update, "모델을 환경 기본값으로 되돌렸습니다.\n\n" + self._format_runtime_settings(), self._settings_keyboard()) return if data == "model_latest": models = await self._model_candidates(chat_id) model = _latest_model(models) self.state.update_runtime_settings({"model": model}) await self._close_all_clients() await self._reply_or_edit(update, f"모델을 최신 추천값 {model}(으)로 설정했습니다.\n\n{self._format_runtime_settings()}", self._settings_keyboard()) return if data.startswith("model_pick:"): await self._set_model_from_button(update, data.split(":", 1)[1]) return if data.startswith("use_session:"): resumed_id = await self._use_session_for_chat(chat_id, data.split(":", 1)[1]) await self._reply_or_edit(update, f"Codex 세션을 전환했습니다.\n세션ID={resumed_id}") return if data.startswith("archive_session:"): thread_id = data.split(":", 1)[1] await self._archive_session_for_chat(chat_id, thread_id) await self._reply_or_edit(update, f"세션을 보관했습니다.\n세션ID={thread_id}") return if data.startswith("unarchive_session:"): thread_id = data.split(":", 1)[1] await self.management_runner.unarchive_thread(thread_id) await self._reply_or_edit(update, f"세션을 복원했습니다.\n세션ID={thread_id}") return except Exception as exc: await self._reply_or_edit(update, f"버튼 작업을 처리하지 못했습니다: {exc}") return await self._reply_or_edit(update, "알 수 없는 버튼 동작입니다.") def _main_keyboard(self) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ InlineKeyboardButton("설정", callback_data="settings"), InlineKeyboardButton("세션", callback_data="sessions"), ], [ InlineKeyboardButton("스케줄", callback_data="schedules"), InlineKeyboardButton("기록", callback_data="records"), ], [ InlineKeyboardButton("모델", callback_data="settings_models"), InlineKeyboardButton("할 일", callback_data="records_cat:todo"), ], ] ) def _settings_keyboard(self) -> InlineKeyboardMarkup: settings = self._runtime_settings() speed_mode = str(settings.get("speed_mode") or "standard") return InlineKeyboardMarkup( [ [ InlineKeyboardButton(_checked_label("보통", speed_mode == "standard"), callback_data="set_speed:standard"), InlineKeyboardButton(_checked_label("고속", speed_mode == "fast"), callback_data="set_speed:fast"), ], [ InlineKeyboardButton("모델 선택", callback_data="settings_models"), InlineKeyboardButton("추론강도", callback_data="settings_effort"), ], [ InlineKeyboardButton("세션", callback_data="sessions"), InlineKeyboardButton("스케줄", callback_data="schedules"), ], [ InlineKeyboardButton("개인 기록", callback_data="records"), ], ] ) async def _set_speed_mode_from_button(self, update: Update, mode: str) -> None: if mode not in {"standard", "fast"}: await self._reply_or_edit(update, "알 수 없는 속도 모드입니다.", self._settings_keyboard()) return self.state.update_runtime_settings({"speed_mode": mode}) await self._close_all_clients() await self._reply_or_edit( update, f"속도 모드를 {_display_speed(mode)}(으)로 설정했습니다.\n\n{self._format_runtime_settings()}", self._settings_keyboard(), ) async def _set_effort_from_button(self, update: Update, effort: str) -> None: if effort == "default": self.state.update_runtime_settings({"effort": None}) action = "추론강도를 환경 기본값으로 되돌렸습니다." elif effort in EFFORT_VALUES: self.state.update_runtime_settings({"effort": effort}) action = f"추론강도를 {effort}(으)로 설정했습니다." else: await self._reply_or_edit(update, "알 수 없는 추론강도입니다.", self._settings_keyboard()) return await self._close_all_clients() await self._reply_or_edit(update, f"{action}\n\n{self._format_runtime_settings()}", self._settings_keyboard()) async def _set_model_from_button(self, update: Update, index_text: str) -> None: chat_id = update.effective_chat.id try: index = int(index_text) except ValueError: await self._reply_or_edit(update, "알 수 없는 모델 선택입니다.", self._settings_keyboard()) return models = self.model_menus.get(chat_id) if not models: models = await self._model_candidates(chat_id) if index < 0 or index >= len(models): await self._reply_or_edit(update, "모델 목록이 오래되었습니다. /models로 다시 열어주세요.", self._settings_keyboard()) return model = models[index] self.state.update_runtime_settings({"model": model}) await self._close_all_clients() await self._reply_or_edit(update, f"모델을 {model}(으)로 설정했습니다.\n\n{self._format_runtime_settings()}", self._settings_keyboard()) async def _show_effort_menu(self, update: Update) -> None: settings = self._runtime_settings() current = settings.get("effort") rows: list[list[InlineKeyboardButton]] = [] rows.append([InlineKeyboardButton(_checked_label("기본값", current is None), callback_data="set_effort:default")]) for effort in ["minimal", "low", "medium", "high", "xhigh"]: rows.append([InlineKeyboardButton(_checked_label(effort, current == effort), callback_data=f"set_effort:{effort}")]) rows.append([InlineKeyboardButton("설정으로 돌아가기", callback_data="settings")]) await self._reply_or_edit(update, "추론강도를 선택해 주세요.", InlineKeyboardMarkup(rows)) async def _show_model_menu(self, update: Update) -> None: chat_id = update.effective_chat.id models = await self._model_candidates(chat_id) current = self._runtime_settings().get("model") latest = _latest_model(models) rows: list[list[InlineKeyboardButton]] = [ [InlineKeyboardButton(_checked_label("기본값", current is None), callback_data="model_default")], [InlineKeyboardButton(f"최신 추천: {latest}", callback_data="model_latest")], ] for index, model in enumerate(models[:12]): rows.append( [InlineKeyboardButton(_checked_label(model, current == model), callback_data=f"model_pick:{index}")] ) rows.append([InlineKeyboardButton("목록 새로고침", callback_data="settings_models")]) rows.append([InlineKeyboardButton("설정으로 돌아가기", callback_data="settings")]) self.model_menus[chat_id] = models await self._reply_or_edit( update, "모델을 선택해 주세요.\n목록은 NAS의 Codex 모델 카탈로그를 우선 사용합니다.", InlineKeyboardMarkup(rows), ) async def _model_candidates(self, chat_id: int) -> list[str]: models = await self._discover_codex_models() if not models: models = self._fallback_models() models = _sort_models(models, preferred=self.config.app_server_model) self.model_menus[chat_id] = models return models async def _resolve_model_input(self, lowered: str, original: str) -> str: if lowered in {"latest", "최신"}: return _latest_model(await self._model_candidates(chat_id=0)) return MODEL_ALIASES.get(lowered, original) async def _discover_codex_models(self) -> list[str]: for args in (["debug", "models"], ["debug", "models", "--bundled"]): try: proc = await asyncio.create_subprocess_exec( self.config.codex_bin, *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, _stderr = await asyncio.wait_for(proc.communicate(), timeout=20) except Exception: continue if proc.returncode != 0: continue try: payload = json.loads(stdout.decode("utf-8", errors="replace")) except json.JSONDecodeError: continue models = _extract_model_ids(payload) if models: return models return [] def _fallback_models(self) -> list[str]: return _dedupe( [ self.config.app_server_model or "", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", ] ) async def _show_schedule_list(self, update: Update) -> None: chat_id = update.effective_chat.id schedules = self.state.list_schedules(chat_id=chat_id) if not schedules: await self._reply_or_edit( update, "등록된 스케줄이 없습니다.", InlineKeyboardMarkup([[InlineKeyboardButton("설정으로 돌아가기", callback_data="settings")]]), ) return lines = ["등록된 스케줄:"] rows: list[list[InlineKeyboardButton]] = [] for schedule in schedules[:20]: schedule_id = int(schedule.get("id") or 0) lines.append(_format_schedule_display(schedule)) rows.append( [ InlineKeyboardButton( f"삭제 #{schedule_id}", callback_data=f"schedule_delete_confirm:{schedule_id}", ) ] ) rows.append([InlineKeyboardButton("새로고침", callback_data="schedules")]) rows.append([InlineKeyboardButton("설정으로 돌아가기", callback_data="settings")]) await self._reply_or_edit(update, "\n".join(lines), InlineKeyboardMarkup(rows)) async def _confirm_schedule_delete(self, update: Update, schedule_id_text: str) -> None: try: schedule_id = int(schedule_id_text) except ValueError: await self._reply_or_edit(update, "알 수 없는 스케줄입니다.") return schedule = self.state.get_schedule(schedule_id) if not schedule or int(schedule.get("chat_id", 0)) != update.effective_chat.id: await self._reply_or_edit(update, "해당 스케줄을 찾을 수 없습니다.") return rows = [ [ InlineKeyboardButton("삭제 확정", callback_data=f"schedule_delete:{schedule_id}"), InlineKeyboardButton("취소", callback_data="schedules"), ] ] await self._reply_or_edit( update, f"이 스케줄을 삭제할까요?\n{_format_schedule_display(schedule)}", InlineKeyboardMarkup(rows), ) async def _delete_schedule_from_button(self, update: Update, schedule_id_text: str) -> None: try: schedule_id = int(schedule_id_text) except ValueError: await self._reply_or_edit(update, "알 수 없는 스케줄입니다.") return removed = self.state.remove_schedule(schedule_id, chat_id=update.effective_chat.id) if not removed: await self._reply_or_edit(update, "해당 스케줄을 찾을 수 없습니다.") return await self._reply_or_edit( update, f"스케줄을 삭제했습니다.\n{_format_schedule_display(removed)}", InlineKeyboardMarkup([[InlineKeyboardButton("스케줄 목록", callback_data="schedules")]]), ) async def _show_record_list( self, update: Update, category: str | None = None, status: str | None = None, query: str | None = None, ) -> None: chat_id = update.effective_chat.id records = self.state.list_personal_records( chat_id=chat_id, category=category, status=status, query=query, limit=20, ) if not records: rows = self._record_category_rows() rows.append([InlineKeyboardButton("설정으로 돌아가기", callback_data="settings")]) title = _record_list_title(category, status, query) await self._reply_or_edit(update, f"{title}: 없음", InlineKeyboardMarkup(rows)) return lines = [_record_list_title(category, status, query) + ":"] rows: list[list[InlineKeyboardButton]] = [] for record in records: record_id = int(record.get("id") or 0) lines.append(_format_record_display(record)) row: list[InlineKeyboardButton] = [] if record.get("status", "active") == "active" and record.get("category") in {"todo", "purchase"}: row.append(InlineKeyboardButton(f"완료 #{record_id}", callback_data=f"record_done:{record_id}")) row.append(InlineKeyboardButton(f"삭제 #{record_id}", callback_data=f"record_delete_confirm:{record_id}")) rows.append(row) rows.extend(self._record_category_rows()) rows.append([InlineKeyboardButton("새로고침", callback_data=f"records_cat:{category}" if category else "records")]) rows.append([InlineKeyboardButton("설정으로 돌아가기", callback_data="settings")]) await self._reply_or_edit(update, "\n".join(lines), InlineKeyboardMarkup(rows)) def _record_category_rows(self) -> list[list[InlineKeyboardButton]]: return [ [ InlineKeyboardButton("업무이력", callback_data="records_cat:work_log"), InlineKeyboardButton("할 일", callback_data="records_cat:todo"), ], [ InlineKeyboardButton("구매", callback_data="records_cat:purchase"), InlineKeyboardButton("생일", callback_data="records_cat:birthday"), ], [ InlineKeyboardButton("메모", callback_data="records_cat:note"), InlineKeyboardButton("연락처", callback_data="records_cat:contact"), ], ] async def _mark_record_done_from_button(self, update: Update, record_id_text: str) -> None: try: record_id = int(record_id_text) except ValueError: await self._reply_or_edit(update, "알 수 없는 기록입니다.") return updated = self.state.update_personal_record( record_id, {"status": "done"}, chat_id=update.effective_chat.id, ) if not updated: await self._reply_or_edit(update, "해당 기록을 찾을 수 없습니다.") return await self._reply_or_edit( update, f"완료로 표시했습니다.\n{_format_record_display(updated)}", InlineKeyboardMarkup([[InlineKeyboardButton("기록 목록", callback_data="records")]]), ) async def _confirm_record_delete(self, update: Update, record_id_text: str) -> None: try: record_id = int(record_id_text) except ValueError: await self._reply_or_edit(update, "알 수 없는 기록입니다.") return record = self.state.get_personal_record(record_id) if not record or int(record.get("chat_id", 0)) != update.effective_chat.id: await self._reply_or_edit(update, "해당 기록을 찾을 수 없습니다.") return rows = [ [ InlineKeyboardButton("삭제 확정", callback_data=f"record_delete:{record_id}"), InlineKeyboardButton("취소", callback_data="records"), ] ] await self._reply_or_edit( update, f"이 기록을 삭제할까요?\n{_format_record_display(record)}", InlineKeyboardMarkup(rows), ) async def _delete_record_from_button(self, update: Update, record_id_text: str) -> None: try: record_id = int(record_id_text) except ValueError: await self._reply_or_edit(update, "알 수 없는 기록입니다.") return removed = self.state.remove_personal_record(record_id, chat_id=update.effective_chat.id) if not removed: await self._reply_or_edit(update, "해당 기록을 찾을 수 없습니다.") return await self._reply_or_edit( update, f"기록을 삭제했습니다.\n{_format_record_display(removed)}", InlineKeyboardMarkup([[InlineKeyboardButton("기록 목록", callback_data="records")]]), ) async def archive_session(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return chat_state = self.state.get_chat(update.effective_chat.id) thread_id = _args_text(context) or chat_state.session_id if not thread_id: await self._reply(update, "사용법: /archive_session [thread_id]") return try: await self._archive_session_for_chat(update.effective_chat.id, thread_id) except Exception as exc: await self._reply(update, f"세션을 보관하지 못했습니다: {exc}") return await self._reply(update, f"세션을 보관했습니다.\n세션ID={thread_id}") async def unarchive_session(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return thread_id = _args_text(context) if not thread_id: await self._reply(update, "사용법: /unarchive_session ") return try: await self.management_runner.unarchive_thread(thread_id) except Exception as exc: await self._reply(update, f"세션을 복원하지 못했습니다: {exc}") return await self._reply(update, f"세션을 복원했습니다.\n세션ID={thread_id}") async def plain_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): return text = update.effective_message.text if update.effective_message else "" text = text.strip() if not text: return await self._run_codex_from_update(update, text) async def _run_codex_from_update(self, update: Update, prompt: str) -> None: chat_id = update.effective_chat.id if update.effective_chat: await update.effective_chat.send_action(ChatAction.TYPING) result, fallback_note = await self._run_codex_for_chat( chat_id=chat_id, prompt=prompt, sandbox=self.config.readonly_sandbox, update_chat_state=True, ) await self._reply_result(update, result, fallback_note) async def _reply_result(self, update: Update, 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._reply(update, 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._reply(update, f"실패(returncode={result.returncode})\n\n{note_text}{result.final_message}{stderr_text}") async def _run_codex_for_chat( self, chat_id: int, prompt: str, sandbox: str, update_chat_state: bool, session_id_override: str | None = None, ) -> tuple[CodexResult, str]: chat_state = self.state.get_chat(chat_id) cwd = Path(chat_state.repo_path) if chat_state.repo_path else self.config.workspace_root if not cwd.exists(): return ( CodexResult( ok=False, final_message=f"작업 폴더가 존재하지 않습니다: {cwd}", session_id=chat_state.session_id, returncode=1, ), "", ) bridge_prompt = self._with_bridge_context( user_prompt=prompt, chat_id=chat_id, chat_state=chat_state, cwd=cwd, ) temp_state = ChatState( repo_path=chat_state.repo_path, session_id=session_id_override or chat_state.session_id, session_backend="app-server" if session_id_override else chat_state.session_backend, ) skip_git_repo_check = chat_state.repo_path is None and sandbox == self.config.readonly_sandbox result, backend_used, fallback_note = await self._run_with_backend( chat_id=chat_id, prompt=bridge_prompt, cwd=cwd, sandbox=sandbox, chat_state=temp_state, skip_git_repo_check=skip_git_repo_check, ) if update_chat_state: chat_state.session_id = result.session_id or chat_state.session_id if result.session_id: chat_state.session_backend = backend_used self.state.update_chat(chat_id, chat_state) return result, fallback_note async def _run_with_backend( self, chat_id: int, prompt: str, cwd: Path, sandbox: str, chat_state: ChatState, skip_git_repo_check: bool, ) -> tuple[CodexResult, str, str]: if self._should_use_app_server(sandbox): app_session = _session_id_for_backend(chat_state, "app-server") app_result = await self._client_for_chat(chat_id).run( prompt=prompt, cwd=cwd, sandbox=sandbox, session_id=app_session, skip_git_repo_check=skip_git_repo_check, ) if app_result.ok or self.config.codex_backend == "app-server": return app_result, "app-server", "" exec_session = _session_id_for_backend(chat_state, "exec") exec_result = await self.exec_runner.run( prompt=prompt, cwd=cwd, sandbox=sandbox, session_id=exec_session, skip_git_repo_check=skip_git_repo_check, ) note = f"app-server 실행이 실패해서 이번 요청은 exec fallback으로 처리했습니다.\napp-server 오류: {app_result.final_message}" if not exec_result.ok: exec_result.final_message = ( "app-server와 exec fallback이 모두 실패했습니다.\n\n" f"app-server: {app_result.final_message}\n\n" f"exec: {exec_result.final_message}" ) return exec_result, "exec", note exec_session = _session_id_for_backend(chat_state, "exec") exec_result = await self.exec_runner.run( prompt=prompt, cwd=cwd, sandbox=sandbox, session_id=exec_session, skip_git_repo_check=skip_git_repo_check, ) return exec_result, "exec", "" async def schedule_loop(self, app: Application) -> None: await asyncio.sleep(5) while True: try: await self._close_idle_clients() for schedule in self.state.claim_due_schedules(): app.create_task(self._run_schedule(app, schedule)) except Exception as exc: print(f"schedule loop error: {exc}", flush=True) await asyncio.sleep(max(5, self.config.schedule_poll_seconds)) async def _close_idle_clients(self) -> None: closed_chat_ids: list[int] = [] for chat_id, client in list(self.chat_clients.items()): if await client.close_if_idle(self.config.persistent_ws_idle_seconds): closed_chat_ids.append(chat_id) for chat_id in closed_chat_ids: print(f"closed idle app-server websocket for chat_id={chat_id}", flush=True) async def _close_client_for_chat(self, chat_id: int) -> None: client = self.chat_clients.pop(chat_id, None) if client is not None: await client.close() async def _close_all_clients(self) -> None: clients = list(self.chat_clients.values()) self.chat_clients.clear() await asyncio.gather(*(client.close() for client in clients), return_exceptions=True) async def _run_schedule(self, app: Application, schedule: dict) -> None: chat_id = int(schedule["chat_id"]) name = schedule.get("name") or f"스케줄 #{schedule.get('id')}" prompt = _scheduled_prompt(schedule) try: await app.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) result, fallback_note = await self._run_codex_for_chat( chat_id=chat_id, prompt=prompt, sandbox=self.config.readonly_sandbox, update_chat_state=False, session_id_override=schedule.get("thread_id") or None, ) if result.session_id and not schedule.get("thread_id"): schedule["thread_id"] = result.session_id self.state.update_schedule(schedule) if result.ok: text = f"스케줄 실행 결과: {name}\n\n{result.final_message}" if fallback_note: text = f"{fallback_note}\n\n{text}" else: text = f"스케줄 실행 실패: {name}\n\n{result.final_message}" for chunk in _split_message(text): await app.bot.send_message(chat_id=chat_id, text=chunk) except Exception as exc: await app.bot.send_message(chat_id=chat_id, text=f"스케줄 실행 실패: {name}\n\n{exc}") def _client_for_chat(self, chat_id: int) -> PersistentAppServerRunner: client = self.chat_clients.get(chat_id) if client is None: client = PersistentAppServerRunner( url=self.config.app_server_url, timeout_seconds=self.config.codex_timeout_seconds, ) self.chat_clients[chat_id] = client self._apply_runtime_settings(client) return client async def _use_session_for_chat(self, chat_id: int, thread_id: str) -> str: self._apply_runtime_settings(self.management_runner) resumed_id = await self.management_runner.resume_thread(thread_id) chat_state = self.state.get_chat(chat_id) chat_state.session_id = resumed_id chat_state.session_backend = "app-server" self.state.update_chat(chat_id, chat_state) await self._close_client_for_chat(chat_id) return resumed_id async def _archive_session_for_chat(self, chat_id: int, thread_id: str) -> None: await self.management_runner.archive_thread(thread_id) chat_state = self.state.get_chat(chat_id) if chat_state.session_id == thread_id: chat_state.session_id = None chat_state.session_backend = None self.state.update_chat(chat_id, chat_state) await self._close_client_for_chat(chat_id) def _runtime_defaults(self) -> dict[str, str | None]: return { "model": self.config.app_server_model, "effort": self.config.app_server_reasoning_effort, "speed_mode": self.config.app_server_speed_mode, } def _runtime_settings(self) -> dict[str, str | None]: settings = self.state.get_runtime_settings(self._runtime_defaults()) speed_mode = str(settings.get("speed_mode") or "standard").lower() if speed_mode not in {"standard", "fast"}: speed_mode = "standard" settings["speed_mode"] = speed_mode return settings def _apply_runtime_settings(self, runner: AppServerRunner) -> None: settings = self._runtime_settings() runner.model = settings.get("model") runner.effort = settings.get("effort") runner.service_tier = _service_tier_for_speed(str(settings.get("speed_mode") or "standard")) def _format_runtime_settings(self) -> str: settings = self._runtime_settings() speed_mode = str(settings.get("speed_mode") or "standard") service_tier = _service_tier_for_speed(speed_mode) or "standard" return "\n".join( [ f"모델={settings.get('model') or '(기본값)'}", f"추론강도={settings.get('effort') or '(기본값)'}", f"속도={_display_speed(speed_mode)}", f"서비스티어={service_tier}", ] ) def _with_bridge_context( self, user_prompt: str, chat_id: int, chat_state: ChatState, cwd: Path, ) -> str: thread_id = chat_state.session_id or "(not assigned yet)" schedules = self.state.list_schedules(chat_id=chat_id) schedule_lines = "\n".join(f"- {format_schedule(schedule)}" for schedule in schedules[:12]) if not schedule_lines: schedule_lines = "- none" records = self.state.list_personal_records(chat_id=chat_id, limit=8) record_lines = "\n".join(f"- {format_personal_record(record)}" for record in records) if not record_lines: record_lines = "- none" runtime_settings = self._format_runtime_settings() return ( "[Telegram bridge context]\n" "You are connected to the user through a private Telegram bridge.\n" f"Default Telegram chat_id for replies, schedules, and notifications: {chat_id}\n" f"Current Codex app-server thread_id: {thread_id}\n" f"Current working directory: {cwd}\n" "Current runtime settings:\n" f"{runtime_settings}\n" "If the user asks to schedule, remind, monitor, or notify later, use the " "telegram_create_schedule MCP tool. Use the default chat_id above and do not ask " "for a destination unless the user explicitly wants a different chat. The schedule " "tool's prompt field should describe only the task to perform at run time.\n" "If the user asks to list or delete schedules, use telegram_list_schedules or " "telegram_delete_schedule.\n" "If the user asks you to remember, save, log, manage, or later recall personal " "assistant information, use the personal record MCP tools. Categories are: " "work_log, todo, purchase, birthday, contact, note, preference. Store durable " "facts such as project history, todos, purchase candidates, birthdays, contacts, " "and personal preferences when the user explicitly provides them or asks to save them. " "Use telegram_list_personal_records before answering questions that depend on saved " "personal history or personal assistant records.\n" "Currently registered bridge schedules for this chat:\n" f"{schedule_lines}\n" "Recent personal assistant records for this chat:\n" f"{record_lines}\n" "[/Telegram bridge context]\n\n" "User message:\n" f"{user_prompt}" ) def _should_use_app_server(self, sandbox: str) -> bool: if self.config.codex_backend == "exec": return False return sandbox == self.config.readonly_sandbox def _resolve_workspace_path(self, requested: str) -> Path: root = self.config.workspace_root.resolve() path = Path(requested) if not path.is_absolute(): path = root / requested resolved = path.resolve() if resolved != root and root not in resolved.parents: raise ValueError(f"작업 경로는 workspace root 안에 있어야 합니다: {root}") return resolved def _session_id_for_backend(chat_state: ChatState, backend: str) -> str | None: if not chat_state.session_id: return None if chat_state.session_backend == backend: return chat_state.session_id if chat_state.session_backend is None and backend == "exec": return chat_state.session_id return None def _service_tier_for_speed(speed_mode: str) -> str | None: return "fast" if speed_mode == "fast" else None def _display_speed(speed_mode: str) -> str: return "fast(고속)" if speed_mode == "fast" else "standard(보통)" def _args_text(context: ContextTypes.DEFAULT_TYPE) -> str: return " ".join(context.args).strip() def _checked_label(label: str, checked: bool) -> str: return f"✓ {label}" if checked else label def _extract_model_ids(payload: Any) -> list[str]: candidates: list[str] = [] def visit(value: Any, key: str = "") -> None: if isinstance(value, dict): for nested_key, nested_value in value.items(): visit(nested_value, str(nested_key).lower()) return if isinstance(value, list): for item in value: visit(item, key) return if not isinstance(value, str): return if key in {"id", "model", "model_id", "name", "slug"} or _looks_like_model_id(value): if _looks_like_model_id(value): candidates.append(value.strip()) visit(payload) return _dedupe(candidates) def _looks_like_model_id(value: str) -> bool: value = value.strip() if not value or len(value) > 80 or " " in value: return False lowered = value.lower() if lowered.startswith(("gpt-", "o1", "o3", "o4")): return True return "codex" in lowered and re.match(r"^[a-z0-9._:/-]+$", lowered) is not None def _dedupe(values: list[str]) -> list[str]: seen: set[str] = set() result: list[str] = [] for value in values: value = value.strip() if not value or value in seen: continue seen.add(value) result.append(value) return result def _sort_models(models: list[str], preferred: str | None) -> list[str]: latest = _latest_model(models) def key(model: str) -> tuple[int, tuple[int, ...], str]: if preferred and model == preferred: return (0, (), model) if model == latest: return (1, (), model) if "mini" not in model and "spark" not in model and model.startswith("gpt-"): return (2, tuple(-part for part in _model_version_parts(model)), model) if "mini" in model: return (3, tuple(-part for part in _model_version_parts(model)), model) if "spark" in model or "codex" in model: return (4, tuple(-part for part in _model_version_parts(model)), model) return (5, tuple(-part for part in _model_version_parts(model)), model) return sorted(_dedupe(models), key=key) def _latest_model(models: list[str]) -> str: candidates = [ model for model in models if model.startswith("gpt-") and "mini" not in model and "spark" not in model ] if not candidates: candidates = [model for model in models if model.startswith("gpt-")] if not candidates: return models[0] if models else "gpt-5.5" return max(candidates, key=lambda model: (_model_version_parts(model), model)) def _model_version_parts(model: str) -> list[int]: return [int(part) for part in re.findall(r"\d+", model)] def _format_schedule_display(schedule: dict) -> str: schedule_type = schedule.get("schedule_type", "daily") if schedule_type == "interval": cadence = f"{int(schedule.get('interval_minutes') or 60)}분마다" else: cadence = ( f"매일 {int(schedule.get('hour') or 8):02}:" f"{int(schedule.get('minute') or 0):02} {schedule.get('timezone') or 'Asia/Seoul'}" ) status_map = { "active": "활성", "paused": "일시정지", "deleted": "삭제됨", } status = status_map.get(str(schedule.get("status", "active")), str(schedule.get("status", "active"))) name = schedule.get("name") or "스케줄 작업" return f"#{schedule.get('id')} {name} - {cadence} - {status}" def _format_record_display(record: dict) -> str: category = str(record.get("category") or "note") category_label = RECORD_CATEGORY_LABELS.get(category, category) status_map = { "active": "진행중", "done": "완료", "cancelled": "취소", "archived": "보관", } status = status_map.get(str(record.get("status") or "active"), str(record.get("status") or "active")) title = record.get("title") or "(제목 없음)" date_bits = [] for label, key in (("일자", "date"), ("시작", "start_date"), ("종료", "end_date"), ("기한", "due_date")): if record.get(key): date_bits.append(f"{label}:{record[key]}") tags = record.get("tags") or [] tag_text = f" #{' #'.join(str(tag) for tag in tags)}" if tags else "" date_text = f" ({', '.join(date_bits)})" if date_bits else "" return f"#{record.get('id')} [{category_label}] {title} - {status}{date_text}{tag_text}" def _record_list_title(category: str | None, status: str | None, query: str | None) -> str: if query: return f"개인 기록 검색: {query}" if category: label = RECORD_CATEGORY_LABELS.get(category, category) if status == "active": return f"{label} 목록" return f"{label} 기록" return "개인 기록" def _format_thread_list(result: dict, archived: bool) -> tuple[str, InlineKeyboardMarkup | None]: threads = result.get("data") or result.get("threads") or result.get("items") or [] if not isinstance(threads, list) or not threads: label = "보관된 세션" if archived else "세션" keyboard = InlineKeyboardMarkup( [[InlineKeyboardButton("새로고침", callback_data="sessions_archived" if archived else "sessions")]] ) return f"{label}: 없음", keyboard lines = ["보관된 세션:" if archived else "세션:"] buttons: list[list[InlineKeyboardButton]] = [] for index, thread in enumerate(threads, start=1): if not isinstance(thread, dict): continue thread_id = str(thread.get("id") or thread.get("threadId") or "(unknown)") name = _readable_thread_field(thread.get("name") or thread.get("title"), "(untitled)") status = _readable_thread_field(thread.get("status"), "unknown") cwd = thread.get("cwd") or thread.get("workingDirectory") or "" updated = thread.get("updatedAt") or thread.get("updated_at") or "" lines.append(f"{index}. {name} [{status}]") lines.append(f" id={thread_id}") if cwd: lines.append(f" cwd={cwd}") if updated: lines.append(f" updated={updated}") if thread_id != "(unknown)": action = "복원" if archived else "보관" action_callback = "unarchive_session" if archived else "archive_session" buttons.append( [ InlineKeyboardButton(f"사용 {index}", callback_data=f"use_session:{thread_id}"), InlineKeyboardButton(f"{action} {index}", callback_data=f"{action_callback}:{thread_id}"), ] ) next_cursor = result.get("nextCursor") or result.get("next_cursor") if next_cursor: lines.append(f"\nnext_cursor={next_cursor}") buttons.append( [InlineKeyboardButton("새로고침", callback_data="sessions_archived" if archived else "sessions")] ) if not archived: buttons.append([InlineKeyboardButton("보관된 세션", callback_data="sessions_archived")]) else: buttons.append([InlineKeyboardButton("활성 세션", callback_data="sessions")]) return "\n".join(lines), InlineKeyboardMarkup(buttons) def _readable_thread_field(value: object, fallback: str) -> str: if isinstance(value, str) and value.strip(): return value.strip() if isinstance(value, dict): for key in ("text", "name", "title", "status", "type"): nested = value.get(key) if isinstance(nested, str) and nested.strip(): return nested.strip() return fallback def _scheduled_prompt(schedule: dict) -> str: return ( "This is a scheduled Telegram bridge task. Run the task now and produce a concise " "Telegram-ready response. Do not create another schedule unless the task explicitly " "asks you to change scheduling.\n\n" f"Schedule: {format_schedule(schedule)}\n\n" f"Task:\n{schedule.get('prompt') or ''}" ) def _split_message(text: str, limit: int = 3500) -> list[str]: if len(text) <= limit: return [text] chunks: list[str] = [] remaining = text while remaining: chunks.append(remaining[:limit]) remaining = remaining[limit:] return chunks