Files
nas_connection/app/bot.py

1845 lines
78 KiB
Python

from __future__ import annotations
import asyncio
import json
import re
import time
from dataclasses import dataclass
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로 전달
/work <내용> - 파일 수정, 커밋, 푸시 같은 쓰기 작업 실행
/cancel - 현재 진행 중인 작업 취소
/usage - Codex 사용량/제한 확인
/new - 이 채팅의 Codex 세션 초기화
/status - 브리지 상태 확인
/settings - 모델, 추론강도, 속도 설정 확인
/set_model <model|latest|default> - 모델 변경
/set_effort <minimal|low|medium|high|xhigh|default> - 추론강도 변경
/set_speed <standard|fast|보통|고속|default> - 속도 모드 변경
/fast <on|off|status> - 고속 모드 바로 변경/확인
/models - 모델 선택 버튼 열기
/schedules - 등록된 스케줄 목록/삭제 버튼 열기
/records - 개인 기록 목록/삭제 버튼 열기
/todos - 할 일 목록 열기
/purchases - 구매 후보 목록 열기
/birthdays - 생일 목록 열기
/sessions - app-server 세션 목록 확인
/sessions archived - 보관된 세션 목록 확인
/use_session <thread_id> - 기존 세션으로 전환
/archive_session [thread_id] - 세션 보관
/unarchive_session <thread_id> - 보관된 세션 복원
/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": "취향/설정",
}
@dataclass
class RunningJob:
task: asyncio.Task
started_monotonic: float
prompt_preview: str
sandbox: str
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.running_jobs: dict[int, RunningJob] = {}
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("work", self.work))
app.add_handler(CommandHandler("write", self.work))
app.add_handler(CommandHandler("cancel", self.cancel))
app.add_handler(CommandHandler("usage", self.usage))
app.add_handler(CommandHandler("limits", self.usage))
app.add_handler(CommandHandler("quota", self.usage))
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()
for job in self.running_jobs.values():
job.task.cancel()
await asyncio.gather(
*(client.close() for client in self.chat_clients.values()),
*(job.task for job in self.running_jobs.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 work(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, "사용법: /work <파일 수정, 커밋, 푸시 등 작업 내용>")
return
await self._run_codex_from_update(
update,
prompt,
sandbox=self.config.write_sandbox,
)
async def new(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 job and not job.task.done():
await self._reply(update, "작업이 진행 중입니다. 먼저 /cancel로 취소하거나 작업 완료 후 /new를 사용해 주세요.")
return
self.state.clear_session(chat_id)
await self._close_client_for_chat(chat_id)
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 usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await self._guard(update):
return
await self._reply_usage(update)
async def _reply_usage(self, update: Update) -> None:
try:
account = await self.management_runner.call("account/read", {})
except Exception as exc:
await self._reply(
update,
"Codex 사용량 정보를 가져오지 못했습니다.\n"
f"오류: {exc}\n\n"
"NAS의 Codex app-server가 구버전이거나 계정 정보 조회 표면을 제공하지 않을 수 있습니다.",
)
return
await self._reply(update, _format_codex_usage(account))
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())}"
job_status = self._format_job_status(chat_id)
await self._reply(
update,
"\n".join(
[
f"채팅프로필={self._chat_mode(chat_id)}",
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)}",
job_status,
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 <model|latest|default>")
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 <minimal|low|medium|high|xhigh|default>")
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 <standard|fast|보통|고속|default>")
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 <on|off|status>")
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 <thread_id>")
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 <thread_id>")
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
if _looks_like_usage_question(text):
await self._reply_usage(update)
return
await self._run_codex_from_update(update, text)
async def _run_codex_from_update(
self,
update: Update,
prompt: str,
sandbox: str | None = None,
) -> None:
chat_id = update.effective_chat.id
active_job = self.running_jobs.get(chat_id)
if active_job and not active_job.task.done():
await self._reply(
update,
"이미 작업이 진행 중입니다.\n"
f"{self._format_job_status(chat_id)}\n\n"
"끝난 뒤 새 요청을 보내거나 /status로 상태를 확인해 주세요.",
)
return
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
if auto_repo_path is not None:
await self._close_client_for_chat(chat_id)
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,
)
)
self.running_jobs[chat_id] = RunningJob(
task=task,
started_monotonic=time.monotonic(),
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}",
)
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:
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 _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(
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,
sandbox=sandbox,
)
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
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._default_sandbox_for_chat(chat_id),
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 _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 = f"{self._chat_mode(chat_id)}/{_sandbox_mode_label(job.sandbox, self.config)}"
return (
"현재작업=진행 중\n"
f"작업모드={mode}\n"
f"경과={minutes}{seconds}\n"
f"요청={job.prompt_preview}"
)
def _with_bridge_context(
self,
user_prompt: str,
chat_id: int,
chat_state: ChatState,
cwd: Path,
sandbox: str,
) -> 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()
gitea_context = self._format_gitea_context()
return (
"[Telegram bridge context]\n"
"You are connected to the user through a private Telegram bridge.\n"
f"Chat profile: {self._chat_mode(chat_id)}\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"
f"Current sandbox: {sandbox}\n"
"Current runtime settings:\n"
f"{runtime_settings}\n"
"Configured Gitea integration:\n"
f"{gitea_context}\n"
"If the user asks for repository, commit, issue, branch, or pull-request metadata in Gitea, "
"use the gitea_* MCP tools. Never reveal GITEA_TOKEN or print commands that embed it. "
"For HTTPS git clone/fetch/push, the container may provide GIT_ASKPASS from the "
"configured Gitea environment.\n"
f"{self._chat_mode_instructions(chat_id)}\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 _format_gitea_context(self) -> str:
if not self.config.gitea_base_url:
return "- not configured"
lines = [
f"- base_url={self.config.gitea_base_url}",
f"- username={self.config.gitea_username or '(not set)'}",
f"- email={self.config.gitea_email or '(not set)'}",
f"- default_owner={self.config.gitea_default_owner or '(not set)'}",
f"- default_repo={self.config.gitea_default_repo or '(not set)'}",
f"- token={'configured' if self.config.gitea_token_configured else 'missing'}",
]
return "\n".join(lines)
def _should_use_app_server(self, sandbox: str) -> bool:
if self.config.codex_backend == "exec":
return False
return True
def _chat_mode(self, chat_id: int) -> str:
return "work" if chat_id in self.config.work_chat_ids else "assistant"
def _default_sandbox_for_chat(self, chat_id: int) -> str:
if self._chat_mode(chat_id) == "work":
return self.config.write_sandbox
return self.config.assistant_sandbox
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."
)
return (
"This is the user's personal assistant chat. Focus on schedules, reminders, saved "
"personal records, weather/news briefings, and lightweight Gitea/status lookup. "
"Do not edit repository files, run coding tasks, commit, or push from this chat. "
"When the user describes development work here, summarize it, save it as a todo or "
"create a Gitea issue when useful, and direct execution to the work chat or Codex app."
)
def _auto_select_repo_for_prompt(self, chat_id: int, prompt: str) -> Path | None:
chat_state = self.state.get_chat(chat_id)
if chat_state.repo_path:
return None
repo_path = self._detect_workspace_repo(prompt)
if repo_path is None:
return None
self.state.set_repo(chat_id, repo_path)
return repo_path
def _detect_workspace_repo(self, prompt: str) -> Path | None:
root = self.config.workspace_root.resolve()
if not root.exists():
return None
tokens = _repo_name_tokens(prompt)
if not tokens:
return None
children: dict[str, Path] = {}
try:
for child in root.iterdir():
if child.is_dir():
children[child.name.casefold()] = child.resolve()
except OSError:
return None
for token in tokens:
candidate = children.get(token.casefold())
if candidate and (candidate / ".git").exists():
return candidate
for token in tokens:
candidate = children.get(token.casefold())
if candidate:
return candidate
return None
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 _sandbox_mode_label(sandbox: str, config: Config) -> str:
if sandbox == "danger-full-access" or sandbox == config.write_sandbox:
return "full-access"
if sandbox == "workspace-write":
return "workspace-write"
if sandbox == "read-only" or sandbox == config.readonly_sandbox:
return "read-only"
return sandbox
def _args_text(context: ContextTypes.DEFAULT_TYPE) -> str:
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 _repo_name_tokens(prompt: str) -> list[str]:
tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", prompt)
return _dedupe(tokens)
def _looks_like_usage_question(text: str) -> bool:
normalized = text.casefold()
usage_terms = ("사용량", "남은량", "남은 사용량", "한도", "쿼터", "quota", "limit", "usage", "credits", "크레딧")
if not any(term in normalized for term in usage_terms):
return False
explicit_codex_terms = ("codex", "코덱스", "openai", "gpt", "모델")
if any(term in normalized for term in explicit_codex_terms):
return True
non_codex_terms = ("디스크", "용량", "저장소", "나스", "nas", "gitea", "repo", "cpu", "메모리")
if any(term in normalized for term in non_codex_terms):
return False
implicit_codex_terms = ("주간 사용량", "월간 사용량", "남은 사용량", "남은 한도", "쿼터", "quota", "credits", "크레딧")
return any(term in normalized for term in implicit_codex_terms)
def _format_codex_usage(payload: Any) -> str:
if not isinstance(payload, dict):
return "Codex 사용량 응답 형식을 해석하지 못했습니다."
lines = ["Codex 사용량/제한:"]
account = payload.get("account") if isinstance(payload.get("account"), dict) else payload
plan = _first_present(account, "planType", "plan_type", "plan", "tier")
email = _first_present(account, "email", "userEmail", "user_email")
auth_mode = _first_present(account, "authMode", "auth_mode")
if email:
lines.append(f"계정={email}")
if plan:
lines.append(f"플랜={plan}")
if auth_mode:
lines.append(f"인증={auth_mode}")
rate_limits = payload.get("rateLimitsByLimitId")
if isinstance(rate_limits, dict) and rate_limits:
lines.append("제한:")
for limit_id, bucket in rate_limits.items():
if isinstance(bucket, dict):
lines.append(_format_rate_limit_bucket(str(limit_id), bucket))
else:
legacy = payload.get("rateLimits")
if isinstance(legacy, dict) and legacy:
lines.append("제한:")
lines.append(_format_rate_limit_bucket("codex", legacy))
elif isinstance(legacy, list) and legacy:
lines.append("제한:")
for index, bucket in enumerate(legacy, start=1):
if isinstance(bucket, dict):
lines.append(_format_rate_limit_bucket(str(bucket.get("limitId") or index), bucket))
credits = payload.get("credits")
if isinstance(credits, dict) and credits:
lines.append("크레딧:")
for key, value in credits.items():
if _is_safe_scalar(value):
lines.append(f"- {key}={value}")
if len(lines) == 1:
lines.append("현재 Codex app-server가 사용량/제한 값을 반환하지 않았습니다.")
lines.append("Codex UI의 usage dashboard 또는 CLI 활성 세션의 /status에서도 확인해 보세요.")
return "\n".join(lines)
def _format_rate_limit_bucket(limit_id: str, bucket: dict[str, Any]) -> str:
name = bucket.get("limitName") or bucket.get("name") or limit_id
used_percent = _number_or_none(_value_for_any_key(bucket, "usedPercent", "used_percent"))
remaining = None if used_percent is None else max(0.0, 100.0 - used_percent)
window = _value_for_any_key(bucket, "windowDurationMins", "window_duration_mins")
resets_at = _value_for_any_key(bucket, "resetsAt", "resets_at")
plan = _value_for_any_key(bucket, "planType", "plan_type")
reached = _value_for_any_key(bucket, "rateLimitReachedType", "rate_limit_reached_type")
parts = [f"- {name}"]
if remaining is not None:
parts.append(f"남음≈{remaining:.1f}%")
parts.append(f"사용={used_percent:.1f}%")
if window:
parts.append(f"창={window}")
reset_text = _format_unix_time(resets_at)
if reset_text:
parts.append(f"리셋={reset_text}")
if plan:
parts.append(f"플랜={plan}")
if reached:
parts.append(f"상태={reached}")
return " / ".join(parts)
def _first_present(mapping: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = mapping.get(key)
if value:
return value
return None
def _value_for_any_key(mapping: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in mapping and mapping[key] is not None:
return mapping[key]
return None
def _number_or_none(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _format_unix_time(value: Any) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
number = _number_or_none(value)
if number is None:
return None
if number > 10_000_000_000:
number = number / 1000
try:
from datetime import datetime
return datetime.fromtimestamp(number).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
except Exception:
return str(value)
def _is_safe_scalar(value: Any) -> bool:
return isinstance(value, (str, int, float, bool)) or value is None
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. If repository history is relevant, prefer the Gitea "
"commit tools. Do not describe a missing tool or unavailable detail as a permission "
"problem unless an API error explicitly says permission was denied. Only include a "
"commit summary when the task asks for repository history or a repository can be "
"identified from the task/context.\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