비서봇 bot.py
텔레그램 단일 채팅을 통해 대화·명령·캘린더·파일·작업을 처리하는 비서 중추
python-telegram-bot의 Application/Handler 모델 위에 모든 진입점을 auth_required 데코레이터로 감싸 ALLOWED_CHAT_ID 단일 사용자만 통과시키는 화이트리스트 게이트를 둔다. AI 호출은 최후 수단으로, 메일·캘린더·재무제표·할일 등을 fast-path로 가로채 Claude를 건너뛰는데(캘린더는 의도·복잡도 감지로 단일 등록만 LLM 없이 처리, 복수·정정은 LLM 경로로 분기하고 할일·메일은 자연어 패턴 매칭으로 조회·삭제·ntfy를 LLM 호출 없이 수행), 파일 수신과 PM 신호 추적, 주제 트래커를 통합하며 메모리는 keyword 자동 감지로 도메인별 MEMORY_RULES 적용하고 /project·/full로 override 가능하다. 모든 히스토리는 sanitize로 민감 정보를 마스킹한 뒤 저장한다. 남은 메시지만 run_claude()가 subprocess로 Claude CLI에 위임하며 매 호출마다 새 UUID session-id를 생성하고 응답마다 token_ledger에 비용을 조용히 기록한다. 히스토리는 MAX_HISTORY(10턴)로 제한해 초과분을 Haiku로 요약하여 conversation_log.md에 적층하고, 수신 파일은 7일 후 자동 정리되며 위험 명령은 approval_gate로 분류해 InlineKeyboard 콜백으로 선별 실행한다.
텔레그램 핸들러 등록 + 인증 게이트 + Claude CLI 위임 + fast-path 라우팅 엔트리포인트
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 ""
# ── 히스토리 관리 ──────────────────────────────────────
def load_history() -> list:
if HISTORY_FILE.exists():
return json.loads(HISTORY_FILE.read_text())
return []
def auto_memorize(dropped: list):
"""히스토리 초과 대화를 Haiku로 요약 → memory/conversation_log.md에 저장.
memory_updater.call_haiku_summarizer 를 재사용해 Haiku 호출 중복 제거.
"""
if not dropped:
return
conv_text = "\n".join([
f"{'나' if t['role']=='user' else '비서'}: {t['content'][:300]}"
for t in dropped
])
prompt = (
f"아래 대화에서 향후 대화에 유용한 핵심 사실, 결정, 선호도만 추출해서 "
f"bullet 5줄 이내 한국어로만 답해. 불필요한 설명 없이.\n\n{conv_text}"
)
try:
from memory_updater import call_haiku_summarizer
summary, reason = call_haiku_summarizer(
prompt, script="bot_auto_memorize", timeout=60,
)
except Exception as e:
log.warning(f"auto_memorize 호출 오류: {e}")
return
if not summary:
log.warning(f"auto_memorize 요약 비어있음 (reason={reason})")
return
mem_file = MEMORY_DIR / "conversation_log.md"
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
if not mem_file.exists():
mem_file.write_text(
"---\nname: 대화 자동 메모\n"
키워드 기반 선택적 메모리 로드(detect_context)와 시스템 프롬프트 조립(build_system_prompt)
"files": PROJECT_CODES["notion"],
},
"tasks": {
# fast-path 미매칭 자유형 요청("완료된 것만 지워" 등)이 LLM으로 갈 때
# 할일=Notion 태스크임을 알리는 라우팅 (2026-08-07 '할일 없음' 오답 재발 방지)
"keywords": ["할일", "할 일", "태스크", "task", "투두", "todo", "지연", "리마인더", "마감"],
"files": ["reference_notion_tasks.md"],
},
"calendar": {
"keywords": ["캘린더", "calendar", "일정", "구글캘린더", "schedule", "미팅", "약속", "회의",
"점심", "저녁", "식사"],
"files": PROJECT_CODES["calendar"],
},
"github": {
"keywords": ["github", "깃허브", "git", "레포", "repo", "commit", "push"],
"files": PROJECT_CODES["github"],
},
"news": {
"keywords": ["뉴스", "news", "속보", "breaking", "rss", "briefing", "브리핑", "경제뉴스"],
"files": PROJECT_CODES["news"],
},
"gmail": {
"keywords": ["메일", "mail", "이메일", "email", "gmail", "분류", "수신함"],
"files": PROJECT_CODES["gmail"],
},
ApplicationBuilder 핸들러 매핑 + post_init 하트비트(10분) + 시그널 종료 로깅
tasks_url = f"https://notion.so/{TASKS_DB.replace('-','')}" if TASK_ENABLED else ""
await update.message.reply_text(
f"📊 <b>태스크 대시보드</b>\n\n<a href='{tasks_url}'>Notion에서 열기</a>",
parse_mode="HTML")
else: # list (기본)
tasks = ntask.list_tasks()
await update.message.reply_text(
ntask.format_task_list(tasks),
parse_mode="HTML")
@auth_required
async def handle_task_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""태스크 등록 확인/취소 콜백."""
query = update.callback_query
await query.answer()
action = query.data
if action == "task_confirm":
pending = ctx.user_data.pop("pending_task", None)
if not pending:
await query.message.edit_text("⚠️ 등록 데이터 없음 (만료)")
return
tid = ntask.add_task(
pending["title"],
due=pending.get("due"),
priority=pending.get("priority", "🟡보통"),
assignee=pending.get("assignee", ""),
tags=pending.get("tags"),
project_id=pending.get("project_id"),
remind_at=pending.get("remind_at"),
recurrence=pending.get("recurrence"),
)
if tid:
# 즉시 알림 (remind_offset 있는 경우 JobQueue 등록)
if pending.get("remind_at"):
try:
remind_dt = datetime.fromisoformat(pending["remind_at"])
delay = (remind_dt - datetime.now(ZoneInfo("Asia/Seoul"))).total_seconds()
if delay > 0:
async def _remind_job(context):
await context.bot.send_message(
chat_id=ALLOWED_CHAT_ID,
text=f"🔔 <b>리마인더:</b> {pending['title']}",
parse_mode="HTML")
ctx.application.job_queue.run_once(_remind_job, when=delay)
except (ValueError, TypeError):
pass
await query.message.edit_text(f"✅ 등록 완료: {pending['title']}")
else:
await query.message.edit_text("❌ 등록 실패 (Notion API 오류)")
elif action == "task_cancel":
ctx.user_data.pop("pending_task", None)
await query.message.edit_text("❌ 취소됨")