import * as vscode from 'vscode'; import { logError, logInfo } from '../../utils'; import { readStocksStore, writeStocksStore } from './stocksStore'; import { fetchAllPrices } from './yahooClient'; import { sendStocksReport } from './telegramReport'; import { syncToSheets } from './sheetsSync'; /** * VS Code 시작 시 자동 활성화 — KST 09:00 / 15:00 에: * 1. Yahoo 로 현재가 일괄 갱신 → stocks.json 업데이트 * 2. (선택) Google Sheets 동기화 — g1nation.stocks.spreadsheetId 설정 시 * 3. 텔레그램 보고서 발송 * * 구현 노트: * - setTimeout 단일 chain — 매 firing 후 다음 알람까지 재계산해서 새 setTimeout. * - VS Code 종료 시 disposable 로 clear. * - 시간대는 Asia/Seoul 강제 — 사용자 macOS timezone 과 무관하게 같은 시각에 동작. * - run_kodari_sync.command 의 sleep-loop 구조를 단일 setTimeout 으로 대체. * * 트리거 시각 변경 시 SCHEDULE 만 수정 — 분 단위. */ const SCHEDULE_HOURS_KST = [9, 15]; // 09:00, 15:00 KST let _timer: NodeJS.Timeout | undefined; let _disposed = false; /** Asia/Seoul 기준 *지금* 의 hour/minute. */ function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } { const now = new Date(); const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(now); const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0'); return { date: now, hour: get('hour'), minute: get('minute'), ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`, }; } /** * 지금부터 다음 firing 까지 milliseconds 계산. * SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각. * * 시간대 변환: 사용자 OS 가 KST 가 아닐 수 있으므로 KST 기준 hour/minute 으로 비교 후 * 그 차이를 ms 로 변환 (변환은 단순 산수 — 분 차이 × 60000). */ function msUntilNextRun(): number { const kst = nowInKst(); const todayMinutes = kst.hour * 60 + kst.minute; let bestTodayMinutes: number | null = null; for (const h of SCHEDULE_HOURS_KST) { const targetMinutes = h * 60; if (targetMinutes > todayMinutes) { bestTodayMinutes = targetMinutes; break; } } if (bestTodayMinutes !== null) { return (bestTodayMinutes - todayMinutes) * 60_000; } // 오늘 더 없음 → 내일 첫 시각. const tomorrowFirstMinutes = SCHEDULE_HOURS_KST[0] * 60; const remainingTodayMinutes = 24 * 60 - todayMinutes; return (remainingTodayMinutes + tomorrowFirstMinutes) * 60_000; } /** * 한 번 fire — 가격 갱신 + (선택) Sheets sync + 텔레그램 보고서. * 각 단계는 독립적으로 try/catch — 한 단계 실패해도 다른 단계는 계속. */ async function fireOnce(context: vscode.ExtensionContext): Promise { const kst = nowInKst(); logInfo('Stocks watcher fire 시작.', { kst: `${kst.hour}:${kst.minute}` }); // (1) Yahoo 가격 갱신 try { const store = readStocksStore(); const symbols = store.map(s => s.심볼).filter(Boolean); if (symbols.length === 0) { logInfo('Stocks watcher: 종목 없음 — skip.'); } else { const prices = await fetchAllPrices(symbols); for (const s of store) { const p = prices.get(s.심볼); if (typeof p === 'number') s.현재가 = p; } writeStocksStore(store); } } catch (e: any) { logError('Stocks watcher: 가격 갱신 실패.', { error: e?.message ?? String(e) }); } // (2) Sheets sync — 선택 (spreadsheetId 설정 시에만) try { const cfg = vscode.workspace.getConfiguration('g1nation'); if ((cfg.get('stocks.spreadsheetId') || '').trim()) { const r = await syncToSheets(context); if (!r.ok) logError('Stocks watcher: Sheets 동기화 실패.', { errors: r.errors }); } } catch (e: any) { logError('Stocks watcher: Sheets 호출 실패.', { error: e?.message ?? String(e) }); } // (3) Telegram 보고서 try { const r = await sendStocksReport(context); if (!r.ok) logInfo(`Stocks watcher: 보고서 skip — ${r.reason}`); } catch (e: any) { logError('Stocks watcher: 보고서 호출 실패.', { error: e?.message ?? String(e) }); } } function scheduleNext(context: vscode.ExtensionContext): void { if (_disposed) return; const ms = msUntilNextRun(); const hours = Math.floor(ms / 3600_000); const minutes = Math.floor((ms % 3600_000) / 60_000); logInfo(`Stocks watcher: 다음 firing 까지 ${hours}h ${minutes}m.`); _timer = setTimeout(async () => { try { await fireOnce(context); } catch (e: any) { logError('Stocks watcher: fireOnce 예외.', { error: e?.message ?? String(e) }); } scheduleNext(context); }, ms); } /** * VS Code 시작 시 호출 (extension.ts activate 의 마지막 단계). * 설정 `g1nation.stocks.watcherEnabled` 가 false 이면 활성화 skip. */ export function startStocksWatcher(context: vscode.ExtensionContext): vscode.Disposable { const cfg = vscode.workspace.getConfiguration('g1nation'); const enabled = cfg.get('stocks.watcherEnabled', true); if (!enabled) { logInfo('Stocks watcher: 비활성 (g1nation.stocks.watcherEnabled=false).'); return { dispose: () => {} }; } _disposed = false; scheduleNext(context); logInfo('Stocks watcher: 시작됨.', { schedule: SCHEDULE_HOURS_KST }); return { dispose: () => { _disposed = true; if (_timer) { clearTimeout(_timer); _timer = undefined; } logInfo('Stocks watcher: dispose.'); }, }; } /** 명령으로 즉시 한 번 트리거 (`/stocks watch run` 같은 미래 명령 또는 디버깅). */ export async function runOnceNow(context: vscode.ExtensionContext): Promise { await fireOnce(context); }