비서봇 bot.py
텔레그램 단일 채팅을 통해 대화·명령·캘린더·파일·작업을 처리하는 비서 중추
python-telegram-bot의 Application/Handler 모델 위에 모든 진입점을 auth_required 데코레이터로 감싸 ALLOWED_CHAT_ID 단일 사용자만 통과시키는 화이트리스트 게이트를 둔다. AI 호출은 최후 수단으로, 자연어가 들어오면 먼저 detect_context()가 키워드→메모리 파일 매핑(MEMORY_RULES)을 토큰 0의 순수 Python으로 수행하고, 메일·캘린더·주제추가는 fast-path로 가로채 Claude를 건너뛴다. 남은 메시지만 run_claude()가 subprocess로 Claude CLI(--print/json)에 위임하며 — keychain 접근을 위해 봇 자체가 LaunchAgent로 상주 — 응답마다 token_ledger에 비용을 조용히 기록한다. 히스토리는 MAX_HISTORY(10턴)로 제한해 초과분은 Haiku로 요약(auto_memorize)하여 conversation_log.md에 적층, 컨텍스트 비용을 억제한다. 위험 명령은 approval_gate로 분류해 ntfy 승인을 받고, /plan·/task·이메일 재분류는 InlineKeyboard 콜백으로 선별 실행한다.
텔레그램 핸들러 등록 + 인증 게이트 + Claude CLI 위임 + fast-path 라우팅 엔트리포인트
def run_claude(user_message: str, history: list, memory_files: list[str]) -> str:
system_prompt = build_system_prompt(memory_files)
prompt_parts = []
if history:
prompt_parts.append("[이전 대화]")
for turn in history[-(MAX_HISTORY * 2):]:
role = "나" if turn["role"] == "user" else "비서"
prompt_parts.append(f"{role}: {sanitize(turn['content'])}")
prompt_parts.append("")
prompt_parts.append(f"[현재 메시지]\n{sanitize(user_message)}")
full_prompt = "\n".join(prompt_parts)
cmd = [CLAUDE_BIN, "--print", "--output-format", "json",
"--permission-mode", "default",
"--allowedTools", "Bash,Edit,Write,Read,Glob,Grep,Agent,WebFetch,WebSearch,TodoWrite",
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
"--session-id", str(uuid.uuid4())]
if system_prompt:
cmd += ["--append-system-prompt", system_prompt]
try:
result = subprocess.run(
cmd, input=full_prompt, capture_output=True,
text=True, timeout=300, cwd=WORK_DIR,
env={**os.environ, "PATH": CLAUDE_PATH},
)
except subprocess.TimeoutExpired:
return "⏱ 응답 시간 초과 (300초)"
except Exception as e:
return f"❌ 실행 오류: {e}"
raw = result.stdout.strip()
if not raw and result.returncode != 0:
return f"❌ 오류:\n{result.stderr.strip()[:500]}"
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return raw # JSON 파싱 실패 시 원문 반환
output = parsed.get("result", "") or "(응답 없음)"
# 토큰 기록 실패가 사용자 응답을 오염시키지 않도록 별도 try/except
try:
import token_ledger
usage = parsed.get("usage", {})
token_ledger.record(
cost_usd = parsed.get("total_cost_usd", 0),
input_tokens = usage.get("input_tokens", 0),
output_tokens = usage.get("output_tokens", 0),
cache_creation= usage.get("cache_creation_input_tokens", 0),
cache_read = usage.get("cache_read_input_tokens", 0),
duration_ms = parsed.get("duration_ms", 0),
label = "봇 응답",
notify = False, # 봇은 매 턴 조용히 기록만
)
except Exception as e:
log.warning(f"token_ledger 기록 실패: {e}")
return output
키워드 기반 선택적 메모리 로드(detect_context)와 시스템 프롬프트 조립(build_system_prompt)
def detect_context(message: str) -> list[str]:
"""메시지 키워드로 필요한 메모리 파일 결정 (Python 처리, 토큰 0)"""
msg_lower = message.lower()
files_needed = set()
for rule in MEMORY_RULES.values():
if any(kw in msg_lower for kw in rule["keywords"]):
files_needed.update(rule["files"])
return list(files_needed)
def build_system_prompt(memory_files: list[str]) -> str:
"""필요한 파일만 시스템 프롬프트에 포함"""
parts = []
if CLAUDE_MD.exists():
parts.append(CLAUDE_MD.read_text().strip())
# 자동 메모라이징 로그 항상 포함 (최근 3,000자 제한)
conv_log = MEMORY_DIR / "conversation_log.md"
if conv_log.exists():
content = conv_log.read_text().strip()
if content:
parts.append("[대화 요약 기록]\n" + content[-3000:])
for fname in memory_files:
fpath = MEMORY_DIR / fname
if fpath.exists():
parts.append(fpath.read_text().strip())
return "\n\n---\n\n".join(parts) if parts else ""
ApplicationBuilder 핸들러 매핑 + post_init 하트비트(10분) + 시그널 종료 로깅
def main():
log.info("RC Secretary 시작 (선택적 메모리 로드)")
app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("clear", cmd_clear))
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("full", cmd_full))
app.add_handler(CommandHandler("drop", cmd_drop))
app.add_handler(CommandHandler("inbox", cmd_inbox))
app.add_handler(CommandHandler("project", cmd_project))
app.add_handler(CommandHandler("brief", cmd_brief))
app.add_handler(CommandHandler("run", cmd_run))
app.add_handler(CommandHandler("mail", cmd_mail))
app.add_handler(CommandHandler("fs", cmd_fs))
app.add_handler(CommandHandler("restart", cmd_run))
app.add_handler(CommandHandler("cal", cmd_cal))
app.add_handler(CommandHandler("files", cmd_files))
app.add_handler(CommandHandler("topic", cmd_topic))
app.add_handler(CommandHandler("task", cmd_task))
app.add_handler(CommandHandler("plan", cmd_plan))
app.add_handler(CommandHandler("reauth_cal", cmd_reauth_cal))
app.add_handler(CallbackQueryHandler(handle_task_callback, pattern=r'^task_'))
app.add_handler(CallbackQueryHandler(handle_plan_callback, pattern=r'^plan_'))
app.add_handler(CallbackQueryHandler(handle_email_recat, pattern=r'^email_'))
app.add_handler(CallbackQueryHandler(handle_skill_callback, pattern=r'^skill_'))
app.add_handler(CallbackQueryHandler(handle_approval))
app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
app.add_handler(MessageHandler(filters.COMMAND, handle_unknown_command))
async def post_init(application):
await application.bot.send_message(
chat_id=ALLOWED_CHAT_ID,
text="✅ 봇 재시작 완료"
)
# 10분마다 하트비트 — 헬스체크 로그 미갱신 오탐 방지
async def heartbeat(ctx):
log.info("[heartbeat]")
cleanup_expired_plans()
application.job_queue.run_repeating(heartbeat, interval=600, first=60)
app.post_init = post_init
log.info("폴링 시작")
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
# signal handler: 프로세스 종료 원인 로깅
def _signal_handler(signum, frame):
sig_name = signal.Signals(signum).name
log.warning(f"시그널 수신: {sig_name} ({signum}) — 프로세스 종료 중")
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(sig, _signal_handler)
try:
main()
except Exception as e:
log.error(f"봇 치명적 오류로 종료: {e}", exc_info=True)
raise