import * as vscode from 'vscode'; import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands'; import { logError, logInfo } from '../../utils'; import { readStocksStore } from './stocksStore'; import { classifyAll } from './signalClassifier'; import type { ClassifiedStock } from './types'; /** * 09:00 / 15:00 KST 정기 보고서 — Telegram 으로 발송. * * 사용자 결정 (q2=A): chatId 는 `g1nation.telegram.allowedChatIds[0]` 자동 사용. * 없으면 fallback 으로 `g1nation.stocks.telegramChatId` 별도 설정. 둘 다 없으면 skip + 로그. * 토큰은 telegramCommands 의 SecretStorage 키와 공유. */ function pickChatId(): number | null { const cfg = vscode.workspace.getConfiguration('g1nation'); const allowed = cfg.get('telegram.allowedChatIds', []) || []; if (allowed.length > 0) return allowed[0]; const dedicated = cfg.get('stocks.telegramChatId', 0); if (dedicated && dedicated !== 0) return dedicated; return null; } /** 한 카테고리 (스윙/장기/저평가) 의 종목 리스트를 텔레그램 Markdown 으로 렌더. */ function renderGroup(label: string, stocks: ClassifiedStock[]): string[] { const buyZone = stocks.filter(s => s.signal === 'BUY_ZONE'); const overvalued = stocks.filter(s => s.signal === 'OVERVALUED'); const hold = stocks.filter(s => s.signal === 'HOLD'); const lines: string[] = [`*${label}* (총 ${stocks.length}개)`]; if (buyZone.length > 0) { lines.push(`🚨 매수사정권 (${buyZone.length})`); for (const s of buyZone) { const cur = s.현재가 ? s.현재가.toLocaleString() : '?'; lines.push(` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${s.매수권장가 ?? '-'}`); } } if (overvalued.length > 0) { lines.push(`⚠️ 가격 조정 감시 (${overvalued.length})`); for (const s of overvalued) { const cur = s.현재가 ? s.현재가.toLocaleString() : '?'; lines.push(` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${s.매수권장가 ?? '-'}`); } } if (hold.length > 0 && buyZone.length === 0 && overvalued.length === 0) { // 모두 관망일 때만 그 카운트만 표시 — 종목 일일이 안 나열 (보고서 길이 절약). lines.push(`📊 관망 ${hold.length}건`); } else if (hold.length > 0) { lines.push(`📊 관망 ${hold.length}건 (생략)`); } return lines; } /** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). */ export function buildReportText(now: Date = new Date()): string { const store = readStocksStore(); if (store.length === 0) { return '⚠️ stocks.json 에 종목 없음 — `/stocks add <심볼> <이름>` 으로 추가하세요.'; } const g = classifyAll(store); const kstStr = formatKstTimestamp(now); const out: string[] = [`🦅 *Kodari 정기 보고서* _${kstStr}_`, '']; out.push(...renderGroup('스윙/중기', g.swing), ''); out.push(...renderGroup('장기투자', g.long), ''); out.push(...renderGroup('저평가우량주', g.ultraLow)); return out.join('\n'); } /** Date → "2026-05-25 09:00 KST" 형식. */ function formatKstTimestamp(d: Date): string { const fmt = new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); const parts = fmt.formatToParts(d); const get = (t: string) => parts.find(p => p.type === t)?.value || ''; return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`; } /** * 보고서를 텔레그램으로 전송. 토큰 없거나 chatId 없으면 silent skip (warn 로그). * * 성공/실패 모두 caller (watcher / slash 명령) 가 알 수 있도록 결과 반환. */ export async function sendStocksReport( context: vscode.ExtensionContext, ): Promise<{ ok: boolean; reason?: string }> { const chatId = pickChatId(); if (chatId === null) { const reason = '텔레그램 chatId 미설정 (g1nation.telegram.allowedChatIds 또는 g1nation.stocks.telegramChatId).'; logInfo(`Stocks report skip: ${reason}`); return { ok: false, reason }; } const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; if (!token.trim()) { const reason = '텔레그램 봇 토큰 없음 — `Astra: Set Telegram Bot Token` 으로 등록.'; logInfo(`Stocks report skip: ${reason}`); return { ok: false, reason }; } const text = buildReportText(); const client = new TelegramHttpClient({ getToken: () => token }); try { await client.sendMessage({ chatId, text, parseMode: 'Markdown' }); logInfo('Stocks report 발송 완료.', { chatId, chars: text.length }); return { ok: true }; } catch (e: any) { logError('Stocks report 발송 실패.', { chatId, error: e?.message ?? String(e) }); return { ok: false, reason: e?.message ?? String(e) }; } }