1469 lines
64 KiB
Python
1469 lines
64 KiB
Python
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로 전달
|
|
/work <내용> - 파일 수정, 커밋, 푸시 같은 쓰기 작업 실행
|
|
/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": "취향/설정",
|
|
}
|
|
|
|
|
|
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("work", self.work))
|
|
app.add_handler(CommandHandler("write", self.work))
|
|
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 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
|
|
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 <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
|
|
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
|
|
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=sandbox or 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,
|
|
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.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,
|
|
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"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, 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"
|
|
"Only edit files, commit, or push when this turn is running with a write-capable "
|
|
"sandbox, typically from the Telegram /work command. In read-only turns, inspect and "
|
|
"explain instead of attempting file edits.\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 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
|