import * as vscode from 'vscode'; import { TelegramBot } from './telegramBot'; import type { TelegramHttpClient } from './telegramClient'; import { AIService } from '../../core/services'; import { getActiveBrainProfile, logInfo, logError } from '../../utils'; import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap'; import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever'; import type { SidebarChatProvider } from '../../sidebarProvider'; import { buildTelegramSystemPrompt, looksLikeWorkOrder, buildTelegramCompanyContext, chunkTelegramMessage, } from './promptBuilders'; export interface TelegramSetupDeps { telegramClient: TelegramHttpClient; /** activate() 안 `let provider` 의 *호출 시점* 값. 등록 시점엔 undefined 일 수 있음. */ getProvider: () => SidebarChatProvider | undefined; } /** * Telegram bot 생성 + 메시지 처리 콜백. * * 흐름: * 1) allowlist 검사 (`telegram.allowedChatIds`) — 통과 못 하면 silent drop * 2) 회사 모드 + work-order 라우팅 → CEO 디스패처 (즉시 ack 반환) * 3) Per-chat agent 결정 + scoped RAG 검색 * 4) 시스템 프롬프트 (company-aware) + 대화 히스토리 + 현재 메시지 결합 * 5) AI 호출 → empty/error 시 친절한 fallback 메시지 * 6) reply 저장 + 4096자 청크 분할 * * 모든 단계에서 "조용히 실패하지 말 것" 이 가장 중요 — 사용자가 "응답 없음" 을 * 가장 큰 통증으로 보고했음. 빈 응답이나 예외에도 항상 사람이 읽을 수 있는 * 메시지를 반환. */ export function createTelegramBot( context: vscode.ExtensionContext, deps: TelegramSetupDeps, ): TelegramBot { const { telegramClient, getProvider } = deps; const telegramAi = new AIService(); return new TelegramBot({ client: telegramClient, handle: async (text, chatId) => { const cfg = vscode.workspace.getConfiguration('g1nation'); const allowed = cfg.get('telegram.allowedChatIds', []) || []; if (allowed.length > 0 && !allowed.includes(chatId)) { logInfo('Telegram message from unallowed chat ignored.', { chatId }); return null; } // 진입점 trace — silent failure 진단용. "응답 없음" 신고 시 이 로그가 // 없으면 메시지가 여기까지도 못 옴 (allowlist 또는 polling drop). logInfo('Telegram message received.', { chatId, chars: text.length, preview: text.length > 80 ? text.slice(0, 80) + '…' : text, }); // ── /meet 보류 항목 원격 처리 (P5 HITL) ──────────────────────── // "confirm 1=ok 2=6/20" / "pending" 으로 회의 액션 보류를 텔레그램에서 // 직접 확정 — 데일리 브리핑이 보류를 알려주면 그 자리에서 회신해 등록 완결. const meetCmd = text.match(/^\/?(?:meet\s+)?(confirm|pending|보류)\b\s*(.*)$/i); if (meetCmd) { try { const { loadPending, renderPendingQuestion, processConfirmDecisions } = await import('../../features/datacollect/scheduling/meetRegistration'); const sub = meetCmd[1].toLowerCase(); if (sub === 'pending' || sub === '보류') { const p = loadPending(); return p && p.items.length ? renderPendingQuestion(p) : 'ℹ️ 등록 보류 중인 액션 아이템이 없습니다.'; } const { lines } = await processConfirmDecisions(context, meetCmd[2] || ''); return lines.join('\n').slice(0, 4000); } catch (e: any) { logError('Telegram meet-confirm failed.', { chatId, error: e?.message ?? String(e) }); return `⚠️ 보류 처리 중 오류: ${e?.message ?? e}`; } } // ── 1인 기업 모드 라우팅 ──────────────────────────────────────── // 회사 모드 ON + 메시지가 work *order* 처럼 보이면 (만들어줘/해줘 또는 // "CEO한테 …" 접두) RAG-chat 대신 dispatcher 로. dispatcher 가 끝에 // Telegram mirror 를 쏘므로 사용자는 제대로 된 보고서를 받음. const { readCompanyState } = await import('../../features/company'); const companyState = readCompanyState(context); if (companyState.enabled && looksLikeWorkOrder(text)) { const { appendTelegramMessage } = await import('./conversationHistory'); appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' }); logInfo('Telegram: routing to company turn.', { chatId, preview: text.slice(0, 60) }); // Fire-and-forget — dispatcher 의 secretary mirror 가 최종 보고 전송. // 즉시 ack 를 반환해 사용자는 봇이 명령을 받았다는 신호를 봄. void (async () => { try { await getProvider()!._runCompanyTurn(text); } catch (e: any) { logError('Telegram → company turn failed.', { error: e?.message ?? String(e) }); } })(); const ack = '🧭 CEO에게 전달했어요. 작업 끝나면 보고드릴게요.'; appendTelegramMessage({ chatId, role: 'assistant', text: ack, kind: 'reply' }); return ack; } // Per-chat agent override → global default → mapping default. const perChatAgents = cfg.get>('telegram.agentByChatId', {}) || {}; const perChatAgent = perChatAgents[String(chatId)]; const defaultAgent = cfg.get('telegram.defaultAgent', '') || ''; const agentName = (perChatAgent || defaultAgent || '').trim(); const brain = getActiveBrainProfile(); const brainRoot = brain?.localBrainPath || ''; const scope = resolveScopeForAgent(agentName, brainRoot); // RAG retrieval — agent 매치 없어도 전체 brain 검색해 봇이 계속 유용하게. // buildContextBlock 가 '' 반환하면 섹션 자체를 빼버림 (cleaner prompt). let contextBlock = ''; if (brainRoot) { try { const result = retrieveScoped(text, brainRoot, scope.folders, { maxResults: cfg.get('telegram.contextChunks', 6) ?? 6, }); contextBlock = buildContextBlock(result); logInfo('Telegram RAG retrieval done.', { chatId, agent: scope.agent?.name ?? '(none)', scopedFolders: scope.folders.length, candidates: result.candidateCount, chunks: result.chunks.length, }); } catch (e: any) { logError('Telegram RAG retrieval failed; falling back to plain prompt.', { chatId, error: e?.message ?? String(e), }); } } // Company-aware 시스템 프롬프트 — 회사 모드 ON 일 때 봇은 그 회사의 *비서*. // 모델에게 그렇게 말해줘야 "어디에 저장했어?" 같은 질문에 "파일 시스템 접근 // 못 함" 으로 회피하지 않고 실제 경로를 그대로 답함. const companyContextBlock = buildTelegramCompanyContext(companyState, context); const systemPrompt = buildTelegramSystemPrompt(!!contextBlock) + (companyContextBlock ? `\n\n${companyContextBlock}` : ''); // Per-chat 대화 히스토리 — 없으면 매 inbound 가 fresh turn 이라 // "방금 한 말" 을 봇이 즉시 잊음. AI service 의 {system, user} 표면이 // messages 배열을 안 받아서 user 메시지에 inline 으로 박음. const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } = await import('./conversationHistory'); const history = getRecentMessages(chatId, 10); const historyBlock = formatHistoryForPrompt(history); const pieces: string[] = []; if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`); if (historyBlock) pieces.push(historyBlock); pieces.push(`[USER MESSAGE]\n${text}`); const userMessage = pieces.join('\n\n'); // AI 호출 *전에* user 메시지 persist — 실패해도 다음 inbound 가 발화를 봄. appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' }); try { const result = await telegramAi.chat({ system: systemPrompt, user: userMessage }); logInfo('Telegram AI reply generated.', { chatId, engine: result.engine, model: result.model, empty: result.empty, chars: result.content.length, }); if (result.empty) { // silent 가 아니라 사용자에게 도달. 모델 재시작/질문 단순화 안내. return [ '⚠️ AI 모델이 빈 응답을 반환했습니다.', '', '다음을 시도해보세요:', '• LM Studio에서 모델이 실제로 로드되어 있는지 확인', '• 더 짧고 구체적인 질문으로 다시 보내기', '• `Astra: Test Telegram Connection` 으로 연결 상태 확인', ].join('\n'); } // assistant reply persist → 다음 inbound 가 우리가 한 말을 봄. appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' }); // 4096자 hard limit 분할. 1청크면 그대로, 여러 청크는 "(이어서 i/n)" // 힌트와 함께 join — bot framework 가 한 메시지로 보내지만, drop 없이 // 모든 청크를 시도하는 게 silent loss 보다 나음. const chunks = chunkTelegramMessage(result.content); if (chunks.length === 1) return chunks[0]; return chunks.map((c, i) => i === 0 ? c : `(이어서 ${i + 1}/${chunks.length})\n\n${c}`).join('\n\n---\n\n').slice(0, 4000); } catch (e: any) { // 하드 실패에서도 ALWAYS 응답 — silent failure 가 두 번째로 보고된 통증. logError('Telegram handler threw.', { chatId, error: e?.message ?? String(e) }); return `⚠️ Astra 처리 중 오류가 발생했습니다.\n${e?.message ?? e}\n\nLM Studio가 실행 중인지, 모델이 로드되어 있는지 확인해주세요.`; } }, }); }