""" 텔레그램 봇 최적화 버전 - Polling 최적화 (CPU 사용률 감소) - 별도 프로세스로 분리 - 봇 재시작 명령어 - 원격 명령어 실행 """ import os import asyncio import logging import subprocess import sys from telegram import Update from telegram.ext import Application, CommandHandler, ContextTypes, TypeHandler from dotenv import load_dotenv # 로깅 설정 logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) logging.getLogger("httpx").setLevel(logging.WARNING) class TelegramBotServer: def __init__(self, bot_token): # [최적화] 연결 풀 설정 추가 self.application = Application.builder()\ .token(bot_token)\ .concurrent_updates(True)\ .build() self.bot_instance = None self.is_shutting_down = False self.should_restart = False def set_bot_instance(self, bot): """AutoTradingBot 인스턴스를 주입받음""" self.bot_instance = bot def refresh_bot_instance(self): """IPC에서 최신 봇 인스턴스 데이터 읽기""" # [수정] 모듈 경로 변경 from modules.utils.ipc import BotIPC ipc = BotIPC() self.bot_instance = ipc.get_bot_instance_data() return self.bot_instance is not None async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/start 명령어 핸들러""" await update.message.reply_text( "🤖 **AI Trading Bot Command Center**\n" "명령어 목록:\n" "/status - 현재 봇 및 시장 상태 조회\n" "/portfolio - 현재 보유 종목 및 평가액\n" "/watchlist - 현재 감시 중인 종목 리스트\n" "/update_watchlist - Watchlist 즉시 업데이트\n" "/macro - 거시경제 지표 및 시장 위험도\n" "/system - PC 리소스(CPU/GPU) 상태\n" "/ai - AI 모델 학습 상태 조회\n\n" "**[관리 명령어]**\n" "/restart - 봇 재시작\n" "/exec - 원격 명령어 실행\n" "/stop - 봇 종료", parse_mode="Markdown" ) async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/status: 종합 상태 브리핑""" if not self.refresh_bot_instance(): await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.") return from datetime import datetime now = datetime.now() is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30) status_msg = "✅ **System Status: ONLINE**\n" status_msg += f"🕒 **Market:** {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n" macro_warn = self.bot_instance.is_macro_warning_sent status_msg += f"🌍 **Macro Filter:** {'DANGER 🚨 (Trading Halted)' if macro_warn else 'SAFE 🟢'}\n" await update.message.reply_text(status_msg, parse_mode="Markdown") async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/portfolio: 잔고 조회""" if not self.refresh_bot_instance(): await update.message.reply_text("⚠️ 봇 인스턴스가 연결되지 않았습니다.") return await update.message.reply_text("⏳ 잔고를 조회 중입니다...") try: balance = self.bot_instance.kis.get_balance() if "error" in balance: await update.message.reply_text(f"❌ 잔고 조회 실패: {balance['error']}") return msg = f"💰 **Total Asset:** `{int(balance['total_eval']):,} KRW`\n" \ f"💵 **Deposit:** `{int(balance['deposit']):,} KRW`\n\n" if balance['holdings']: msg += "**[Holdings]**\n" for stock in balance['holdings']: icon = "🔴" if stock['yield'] > 0 else "🔵" msg += f"{icon} **{stock['name']}** `{stock['yield']}%`\n" \ f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n" else: msg += "보유 중인 종목이 없습니다." await update.message.reply_text(msg, parse_mode="Markdown") except Exception as e: await update.message.reply_text(f"❌ Error: {str(e)}") async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/watchlist: 감시 대상 종목""" if not self.refresh_bot_instance(): return target_dict = self.bot_instance.load_watchlist() discovered = list(self.bot_instance.discovered_stocks) msg = f"👀 **Watchlist: {len(target_dict)} items**\n" for code, name in target_dict.items(): themes = self.bot_instance.theme_manager.get_themes(code) theme_str = f" ({', '.join(themes)})" if themes else "" msg += f"- {name}{theme_str}\n" if discovered: msg += f"\n✨ **Discovered Today ({len(discovered)}):**\n" for code in discovered: msg += f"- {code}\n" await update.message.reply_text(msg) async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/update_watchlist: Watchlist 즉시 업데이트""" await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)") try: # [수정] IPC 모드에서도 직접 수행하기 위해 새로운 인스턴스 생성 from modules.services.kis import KISClient from watchlist_manager import WatchlistManager # 독립적인 KIS 클라이언트 생성 from modules.config import Config temp_kis = KISClient() mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE) # 업데이트 수행 (파일 쓰기) summary = mgr.update_watchlist_daily() await update.message.reply_text(summary, parse_mode="Markdown") except Exception as e: await update.message.reply_text(f"❌ 업데이트 실패: {e}") async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/macro: 거시경제 지표 조회 (IPC 데이터 사용)""" if not self.refresh_bot_instance(): await update.message.reply_text("⚠️ 메인 봇 연결 대기 중...") return await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...") try: # [수정] IPC 데이터를 직접 사용하여 출력 (FakeKIS의 _macro_indices 활용) # FakeKIS는 bot_instance.kis에 할당되어 있음 indices = getattr(self.bot_instance.kis, '_macro_indices', {}) if not indices: await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.") return # 리스크 점수 계산 (간이) status = "SAFE" msi = indices.get('MSI', 0) if msi >= 50: status = "DANGER" elif msi >= 30: status = "CAUTION" color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡" msg = f"{color} **Market Risk: {status}**\n\n" if 'MSI' in indices: msg += f"🌡️ **Stress Index:** `{indices['MSI']}`\n" for k, v in indices.items(): if k != "MSI": icon = "🔺" if v.get('change', 0) > 0 else "🔻" msg += f"{icon} **{k}**: {v.get('price', 0)} ({v.get('change', 0)}%)\n" await update.message.reply_text(msg, parse_mode="Markdown") except Exception as e: await update.message.reply_text(f"❌ Error: {e}") async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/system: 시스템 리소스 상태""" if not self.refresh_bot_instance(): await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.") return import psutil cpu = psutil.cpu_percent(interval=1) ram = psutil.virtual_memory().percent # CPU 점유율 상위 3개 프로세스 수집 top_processes = [] for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']): try: proc_info = proc.info if proc_info['name'] == 'System Idle Process': continue top_processes.append(proc_info) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): pass top_processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True) top_3 = top_processes[:3] gpu_status = self.bot_instance.ollama_monitor.get_gpu_status() gpu_msg = f"N/A" if gpu_status and gpu_status.get('name') != 'N/A': gpu_name = gpu_status.get('name', 'GPU') gpu_msg = f"{gpu_name}\n Temp: {gpu_status.get('temp', 0)}°C / VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB" msg = "🖥️ **PC System Status**\n" \ f"🧠 **CPU:** `{cpu}%`\n" \ f"💾 **RAM:** `{ram}%`\n" \ f"🎮 **GPU:** {gpu_msg}\n\n" if top_3: msg += "⚙️ **Top CPU Processes:**\n" for i, proc in enumerate(top_3, 1): proc_name = proc.get('name', 'Unknown') proc_cpu = proc.get('cpu_percent', 0) msg += f" {i}. `{proc_name}` - {proc_cpu:.1f}%\n" await update.message.reply_text(msg, parse_mode="Markdown") async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/ai: AI 모델 학습 상태 조회""" gpu = self.bot_instance.ollama_monitor.get_gpu_status() msg = "🧠 **AI Model Status**\n" msg += f"• **LLM Engine:** Ollama (Llama 3.1)\n" gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti') msg += f"• **Device:** {gpu_name}\n" if gpu: msg += f"• **GPU Load:** `{gpu.get('load', 0)}%`\n" msg += f"• **VRAM Usage:** `{gpu.get('vram_used', 0)}GB` / {gpu.get('vram_total', 0)}GB" await update.message.reply_text(msg, parse_mode="Markdown") async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/restart: 텔레그램 봇 모듈만 재시작""" await update.message.reply_text("🔄 **텔레그램 인터페이스를 재시작합니다...**") # 재시작 플래그 설정 (runner.py에서 감지하여 재시작) self.should_restart = True self.application.stop_running() async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/stop: 봇 종료""" await update.message.reply_text("🛑 **텔레그램 봇을 종료합니다.**") # 종료 플래그 설정 (runner.py에서 루프 탈출) self.should_restart = False self.application.stop_running() async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/exec: 원격 명령어 실행""" # ultimate_handler에서는 context.args가 비어있으므로 직접 파싱 text = update.message.text.strip() parts = text.split(maxsplit=1) # "/exec" 와 나머지 명령어로 분리 if len(parts) < 2: await update.message.reply_text("❌ 사용법: /exec ") return command = parts[1] # "/exec" 이후의 모든 텍스트 await update.message.reply_text(f"⚙️ 실행 중: `{command}`", parse_mode="Markdown") try: # 보안: 위험한 명령어 차단 dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot', 'ipconfig'] if any(keyword in command.lower() for keyword in dangerous_keywords): await update.message.reply_text("⛔ 위험한 명령어는 실행할 수 없습니다.") return # Windows에서는 PowerShell을 명시적으로 사용 import platform if platform.system() == 'Windows': exec_command = ['powershell', '-Command', command] else: exec_command = command # 명령어 실행 (타임아웃 30초) result = subprocess.run( exec_command, shell=False if platform.system() == 'Windows' else True, capture_output=True, text=True, encoding='utf-8', errors='replace', # 인코딩 오류 무시 timeout=30, cwd=os.getcwd() ) # stdout와 stderr 모두 확인 output = result.stdout.strip() if result.stdout else "" error_output = result.stderr.strip() if result.stderr else "" if output and error_output: combined = f"[STDOUT]\n{output}\n\n[STDERR]\n{error_output}" elif output: combined = output elif error_output: combined = f"[ERROR]\n{error_output}" else: combined = "명령어 실행 완료 (출력 없음)" # 출력이 너무 길면 잘라내기 if len(combined) > 3000: combined = combined[:3000] + "\n... (출력이 너무 깁니다)" await update.message.reply_text(f"```\n{combined}\n```", parse_mode="Markdown") except subprocess.TimeoutExpired: await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)") except Exception as e: print(f"❌ [Telegram /exec] Error: {e}") import traceback traceback.print_exc() await update.message.reply_text(f"❌ 실행 오류: {str(e)}") def run(self): """봇 실행 (비동기 polling)""" async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """에러 핸들러""" import traceback # Conflict 에러는 무시 (다른 봇 인스턴스 실행 중) if "Conflict" in str(context.error): print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다. 이 인스턴스를 종료합니다.") if self.application.running: await self.application.stop() return tb_list = traceback.format_exception(None, context.error, context.error.__traceback__) tb_string = ''.join(tb_list) print(f"❌ [Telegram Error] {tb_string}") if isinstance(update, Update) and update.effective_message: try: await update.effective_message.reply_text(f"⚠️ 오류 발생: {context.error}") except: pass async def ultimate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): if not update.message or not update.message.text: return text = update.message.text.strip() print(f"📨 [Telegram] Command Received: {text}") try: if text.startswith("/start"): await self.start_command(update, context) elif text.startswith("/status"): await self.status_command(update, context) elif text.startswith("/portfolio"): await self.portfolio_command(update, context) elif text.startswith("/watchlist"): await self.watchlist_command(update, context) elif text.startswith("/update_watchlist"): await self.update_watchlist_command(update, context) elif text.startswith("/macro"): await self.macro_command(update, context) elif text.startswith("/system"): await self.system_command(update, context) elif text.startswith("/ai"): await self.ai_status_command(update, context) elif text.startswith("/restart"): await self.restart_command(update, context) elif text.startswith("/stop"): await self.stop_command(update, context) elif text.startswith("/exec"): await self.exec_command(update, context) except Exception as e: print(f"❌ Handle Error: {e}") await update.message.reply_text(f"⚠️ Error: {e}") # 에러 핸들러 등록 self.application.add_error_handler(error_handler) self.application.add_handler(TypeHandler(Update, ultimate_handler)) # [최적화] Polling 설정 개선 print("🤖 [Telegram] Command Server Started (Optimized Polling Mode).") try: self.application.run_polling( allowed_updates=Update.ALL_TYPES, stop_signals=None, poll_interval=1.0, # 1초마다 폴링 (기본값 0.0) timeout=10, # 타임아웃 10초 drop_pending_updates=True # 대기 중인 업데이트 무시 ) except Exception as e: if "Conflict" in str(e): print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다.") print(f"⚠️ [Telegram] 기존 봇을 종료하고 다시 시도하세요.") else: print(f"❌ [Telegram] 봇 실행 오류: {e}") import traceback traceback.print_exc()