/** * Google Calendar (iCal) 캐시 — fetch + parse + 회사 _shared/calendar_cache.md 에 저장. * * Connect_origin 의 google_calendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음. * 사용자가 Google Calendar 설정 → "비공개 주소(iCal 형식)" 복사 → 본 모듈에 입력 한 번이면 * 모든 agent 가 매 turn 자동으로 다가오는 일정 컨텍스트를 받는다. * * 보안: * - iCal URL 은 ExtensionContext.globalState 에 저장 (machine-local, git 침범 X). * - 캐시 파일은 회사 디렉토리 `_shared/calendar_cache.md` 에 평문 마크다운으로 저장. * 이 파일은 .gitignore 대상은 아니지만 일정 제목/시각이 들어있음 — 사용자가 commit * 안 하도록 가이드 문구를 README/명령에서 안내한다. */ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { parseIcs, selectUpcoming, IcsEvent } from './icsParser'; /** globalState 키 — iCal URL 과 부수 설정 한 묶음. */ export const CAL_CONFIG_KEY = 'g1nation.calendar.ical'; export interface CalendarConfig { /** Google Calendar 비공개 iCal URL. 빈 문자열이면 iCal 읽기 비활성. */ icalUrl: string; /** 며칠치 미리 가져올지 (default 14). */ daysAhead: number; /** 마지막 성공 fetch ISO timestamp (자동 표시용). */ lastFetchAt?: string; // ── OAuth (쓰기) 관련 필드 — Google Calendar API v3 호출에 사용. ── // 모두 ExtensionContext.globalState 에만 저장 (machine-local). 옵션이라 비어있어도 iCal 읽기는 동작. /** Google Cloud Console 에서 발급한 OAuth Client ID. */ clientId?: string; /** 같은 페이지의 Client Secret (Desktop app 의 secret 은 공개 가능한 식별자). */ clientSecret?: string; /** OAuth 로 받은 refresh token — 진짜 비밀. machine-local. */ refreshToken?: string; /** Calendar API 가 쓰는 캘린더 식별자 — 'primary' 또는 특정 calendarId. */ calendarId?: string; /** end 없는 이벤트 default 길이 (분). */ defaultDurationMinutes?: number; /** 캐시된 access token (만료 전까지 재사용). */ accessToken?: string; /** access token 만료 epoch ms. */ accessTokenExpiresAt?: number; /** 연결된 Google 계정 이메일 (UI 표시용). */ connectedAs?: string; /** OAuth 연결 시각 ISO. */ connectedAt?: string; } /** * Settings + globalState 두 곳에서 읽어 merge. * * • VS Code Settings (g1nation.google.*) — 사용자가 직접 편집 가능한 필드: * clientId, clientSecret, calendarId, defaultDurationMinutes, icalUrl, daysAhead * 이 값이 채워져 있으면 globalState 의 같은 필드보다 *우선*. * * • globalState (CAL_CONFIG_KEY) — 마법사가 자동 관리하는 secret / runtime 필드: * refreshToken, accessToken, accessTokenExpiresAt, connectedAs, connectedAt, lastFetchAt * 사용자는 settings 에서 안 보임. 마법사가 OAuth 완료 후 자동 기록. * * • 옛 사용자 호환: globalState 에 clientId 같은 게 남아있어도 settings 가 비면 * globalState 값으로 fallback. 명시적으로 settings 에 비워두면 globalState 도 무시. */ export function readCalendarConfig(context: vscode.ExtensionContext): CalendarConfig { const raw = (context.globalState.get(CAL_CONFIG_KEY) as Partial | undefined) ?? {}; const s = vscode.workspace.getConfiguration('g1nation.google'); const fromSettings = (key: string): T | undefined => { const v = s.get(key); // 빈 문자열은 "미설정" 으로 취급 — 사용자가 지운 케이스. if (typeof v === 'string' && v.trim() === '') return undefined; return v; }; const clientId = fromSettings('clientId') ?? (typeof raw.clientId === 'string' ? raw.clientId : undefined); const clientSecret = fromSettings('clientSecret') ?? (typeof raw.clientSecret === 'string' ? raw.clientSecret : undefined); const calendarId = fromSettings('calendarId') ?? (typeof raw.calendarId === 'string' ? raw.calendarId : undefined); const defaultDurationMinutes = fromSettings('defaultEventDurationMinutes') ?? (typeof raw.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined); const icalUrl = fromSettings('icalUrl') ?? (typeof raw.icalUrl === 'string' ? raw.icalUrl : ''); const daysAhead = fromSettings('icalDaysAhead') ?? (typeof raw.daysAhead === 'number' && raw.daysAhead > 0 ? raw.daysAhead : 14); return { icalUrl: icalUrl ?? '', daysAhead: daysAhead ?? 14, lastFetchAt: typeof raw.lastFetchAt === 'string' ? raw.lastFetchAt : undefined, clientId, clientSecret, refreshToken: typeof raw.refreshToken === 'string' ? raw.refreshToken : undefined, calendarId, defaultDurationMinutes, accessToken: typeof raw.accessToken === 'string' ? raw.accessToken : undefined, accessTokenExpiresAt: typeof raw.accessTokenExpiresAt === 'number' ? raw.accessTokenExpiresAt : undefined, connectedAs: typeof raw.connectedAs === 'string' ? raw.connectedAs : undefined, connectedAt: typeof raw.connectedAt === 'string' ? raw.connectedAt : undefined, }; } /** * Patch 의 필드를 settings 또는 globalState 의 적절한 곳에 분기 저장. * • settings 로 가는 것 (사용자 편집 가능): clientId, clientSecret, calendarId, * defaultDurationMinutes, icalUrl, daysAhead * • globalState 로 가는 것 (secret / runtime): refreshToken, accessToken, * accessTokenExpiresAt, connectedAs, connectedAt, lastFetchAt * * 한 번에 양쪽을 patch 해도 OK — 분기 자동. */ export async function writeCalendarConfig(context: vscode.ExtensionContext, patch: Partial): Promise { // Settings (g1nation.google.*) 로 가는 필드들. const s = vscode.workspace.getConfiguration('g1nation.google'); const settingsKeys: Array<[keyof CalendarConfig, string]> = [ ['clientId', 'clientId'], ['clientSecret', 'clientSecret'], ['calendarId', 'calendarId'], ['defaultDurationMinutes', 'defaultEventDurationMinutes'], ['icalUrl', 'icalUrl'], ['daysAhead', 'icalDaysAhead'], ]; for (const [src, dst] of settingsKeys) { if (src in patch) { const v = (patch as any)[src]; // undefined → settings 에서 제거 (default 로 복귀). 빈 문자열도 동일 취급. const toWrite = (v === undefined || v === '') ? undefined : v; try { await s.update(dst, toWrite, vscode.ConfigurationTarget.Global); } catch { /* settings 쓰기 실패 시 globalState 로 fallback (다음 read 가 globalState 봄). */ const cur = (context.globalState.get(CAL_CONFIG_KEY) as Partial) ?? {}; await context.globalState.update(CAL_CONFIG_KEY, { ...cur, [src]: v }); } } } // GlobalState 로 가는 secret / runtime 필드. const globalKeys: Array = [ 'refreshToken', 'accessToken', 'accessTokenExpiresAt', 'connectedAs', 'connectedAt', 'lastFetchAt', ]; const cur = (context.globalState.get(CAL_CONFIG_KEY) as Partial) ?? {}; const next: Partial = { ...cur }; let dirty = false; for (const k of globalKeys) { if (k in patch) { (next as any)[k] = (patch as any)[k]; dirty = true; } } if (dirty) await context.globalState.update(CAL_CONFIG_KEY, next); } /** 회사 디렉토리 내부 캐시 파일 경로. workspace 없으면 globalStorage 로 fallback. */ function _cachePath(context: vscode.ExtensionContext): string { const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (ws) return path.join(ws, '.astra', 'company', '_shared', 'calendar_cache.md'); return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'calendar_cache.md'); } export interface RefreshResult { ok: boolean; count: number; error?: string; cachePath: string; } /** * iCal URL 에서 fetch → ICS 파싱 → upcoming 필터 → 마크다운 캐시 파일에 쓰기. * URL 비어있으면 즉시 ok:false 반환 (사용자 안내는 호출자가). */ export async function refreshCalendarCache(context: vscode.ExtensionContext): Promise { const cfg = readCalendarConfig(context); const cachePath = _cachePath(context); if (!cfg.icalUrl) { return { ok: false, count: 0, error: 'iCal URL 이 설정되지 않았습니다. 명령 팔레트에서 "Astra: Google Calendar 연결" 을 먼저 실행하세요.', cachePath }; } if (!/^https?:\/\//.test(cfg.icalUrl)) { return { ok: false, count: 0, error: 'URL 이 http:// 또는 https:// 로 시작하지 않습니다.', cachePath }; } let raw: string; try { // Node 18+ 의 native fetch 사용 — axios / node-fetch 의존성 없이. const res = await fetch(cfg.icalUrl, { method: 'GET', headers: { 'User-Agent': 'Astra-Calendar/1.0' }, signal: AbortSignal.timeout(15000), }); if (!res.ok) { return { ok: false, count: 0, error: `HTTP ${res.status} — URL 이 잘못됐거나 만료됐을 수 있습니다.`, cachePath }; } raw = await res.text(); } catch (e: any) { return { ok: false, count: 0, error: `다운로드 실패: ${e?.message ?? String(e)}`, cachePath }; } const events = parseIcs(raw); const upcoming = selectUpcoming(events, cfg.daysAhead); const now = new Date(); const md = _renderMarkdown(upcoming, cfg.daysAhead, now); try { fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.writeFileSync(cachePath, md, 'utf8'); } catch (e: any) { return { ok: false, count: 0, error: `캐시 저장 실패: ${e?.message ?? String(e)}`, cachePath }; } await writeCalendarConfig(context, { lastFetchAt: now.toISOString() }); return { ok: true, count: upcoming.length, cachePath }; } /** Agent prompt 에 주입할 캐시 본문 읽기. 없으면 빈 문자열. */ export function readCalendarCache(context: vscode.ExtensionContext): string { const cachePath = _cachePath(context); try { if (!fs.existsSync(cachePath)) return ''; return fs.readFileSync(cachePath, 'utf8'); } catch { return ''; } } function _renderMarkdown(events: IcsEvent[], daysAhead: number, now: Date): string { const tsLabel = (d: Date, allDay: boolean) => { const yy = d.getFullYear(), mm = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0'); const wk = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()]; if (allDay) return `${yy}-${mm}-${dd} (${wk})`; const HH = String(d.getHours()).padStart(2, '0'), MM = String(d.getMinutes()).padStart(2, '0'); return `${yy}-${mm}-${dd} (${wk}) ${HH}:${MM}`; }; const lines: string[] = [ '# 📅 다가오는 일정 (Google Calendar)', `_업데이트: ${tsLabel(now, false)} · 향후 ${daysAhead}일_`, '', ]; if (events.length === 0) { lines.push('_없음_'); } else { for (const ev of events) { const ts = tsLabel(ev.start, ev.allDay); const loc = ev.location ? ` — 📍 ${ev.location}` : ''; lines.push(`- **${ts}** · ${ev.summary}${loc}`); } } return lines.join('\n') + '\n'; }