0a97324f1b
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
118 lines
5.1 KiB
TypeScript
118 lines
5.1 KiB
TypeScript
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<number[]>('telegram.allowedChatIds', []) || [];
|
|
if (allowed.length > 0) return allowed[0];
|
|
const dedicated = cfg.get<number>('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) };
|
|
}
|
|
}
|