v2.2.15: Astra Office Refactor & Multi-Service Integration
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function readCalendarConfig(context: vscode.ExtensionContext): CalendarConfig {
|
||||
const raw = context.globalState.get(CAL_CONFIG_KEY) as Partial<CalendarConfig> | undefined;
|
||||
return {
|
||||
icalUrl: typeof raw?.icalUrl === 'string' ? raw.icalUrl : '',
|
||||
daysAhead: typeof raw?.daysAhead === 'number' && raw.daysAhead > 0 ? raw.daysAhead : 14,
|
||||
lastFetchAt: typeof raw?.lastFetchAt === 'string' ? raw.lastFetchAt : undefined,
|
||||
clientId: typeof raw?.clientId === 'string' ? raw.clientId : undefined,
|
||||
clientSecret: typeof raw?.clientSecret === 'string' ? raw.clientSecret : undefined,
|
||||
refreshToken: typeof raw?.refreshToken === 'string' ? raw.refreshToken : undefined,
|
||||
calendarId: typeof raw?.calendarId === 'string' ? raw.calendarId : undefined,
|
||||
defaultDurationMinutes: typeof raw?.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCalendarConfig(context: vscode.ExtensionContext, patch: Partial<CalendarConfig>): Promise<void> {
|
||||
const cur = readCalendarConfig(context);
|
||||
const next: CalendarConfig = { ...cur, ...patch };
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user