Files
connectai/src/features/calendar/calendarCache.ts
T

242 lines
12 KiB
TypeScript

/**
* 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<CalendarConfig> | undefined) ?? {};
const s = vscode.workspace.getConfiguration('g1nation.google');
const fromSettings = <T>(key: string): T | undefined => {
const v = s.get<T>(key);
// 빈 문자열은 "미설정" 으로 취급 — 사용자가 지운 케이스.
if (typeof v === 'string' && v.trim() === '') return undefined;
return v;
};
const clientId = fromSettings<string>('clientId') ?? (typeof raw.clientId === 'string' ? raw.clientId : undefined);
const clientSecret = fromSettings<string>('clientSecret') ?? (typeof raw.clientSecret === 'string' ? raw.clientSecret : undefined);
const calendarId = fromSettings<string>('calendarId') ?? (typeof raw.calendarId === 'string' ? raw.calendarId : undefined);
const defaultDurationMinutes = fromSettings<number>('defaultEventDurationMinutes')
?? (typeof raw.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined);
const icalUrl = fromSettings<string>('icalUrl') ?? (typeof raw.icalUrl === 'string' ? raw.icalUrl : '');
const daysAhead = fromSettings<number>('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<CalendarConfig>): Promise<void> {
// 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<CalendarConfig>) ?? {};
await context.globalState.update(CAL_CONFIG_KEY, { ...cur, [src]: v });
}
}
}
// GlobalState 로 가는 secret / runtime 필드.
const globalKeys: Array<keyof CalendarConfig> = [
'refreshToken', 'accessToken', 'accessTokenExpiresAt',
'connectedAs', 'connectedAt', 'lastFetchAt',
];
const cur = (context.globalState.get(CAL_CONFIG_KEY) as Partial<CalendarConfig>) ?? {};
const next: Partial<CalendarConfig> = { ...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<RefreshResult> {
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';
}