Files
connectai/src/features/calendar/calendarApi.ts
T
koriweb b540923890 fix(calendar): OAuth 토큰 만료/철회 시 복구 안내 에러로 번역 (v2.2.219)
/task·/meet 등록에서 'Token has been expired or revoked.' raw 구글 에러만
노출되어 사용자가 복구 방법을 알 수 없던 문제. getFreshAccessToken 이
expired/revoked/invalid_grant 를 감지하면 재연결 명령("Astra: Google
Calendar OAuth 연결 (쓰기)")과 근본 원인 안내(OAuth 동의 화면 '테스트'
모드 = 리프레시 토큰 7일 만료, '앱 게시'로 영구화)를 함께 표시.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:18:22 +09:00

219 lines
9.5 KiB
TypeScript

/**
* Google Calendar API v3 — event create/list 호출.
*
* access token 은 caller 가 직접 주입한다. 만료 처리는 `withFreshAccessToken`
* 헬퍼가 refresh token 으로 갱신 → 호출 → 401 발생 시 한 번 더 갱신 + 재시도.
*
* 외부 라이브러리(googleapis) 안 씀 — Calendar API 는 REST 라 native fetch 면 충분.
*/
import * as vscode from 'vscode';
import { refreshAccessToken } from './oauth';
import { readCalendarConfig, writeCalendarConfig } from './calendarCache';
const API_BASE = 'https://www.googleapis.com/calendar/v3';
export interface CalendarEventInput {
/** 일정 제목 (필수). */
title: string;
/** ISO 시작 시각 — 'YYYY-MM-DDTHH:MM' (로컬) 또는 'YYYY-MM-DDTHH:MM:SS±HH:MM' (timezone 포함). */
start: string;
/** ISO 종료 시각. 없으면 duration(분) 으로부터 계산. duration 도 없으면 60분. */
end?: string;
/** end 없을 때 시작부터 이만큼 (분 단위, default 60). */
durationMinutes?: number;
description?: string;
location?: string;
/** all-day 일정 여부 — true 면 start 는 'YYYY-MM-DD' 만 받음. */
allDay?: boolean;
}
export interface CreatedEvent {
/** Google 이 발급한 event id. */
id: string;
/** Google Calendar 웹에서 열 수 있는 URL. */
htmlLink: string;
/** API 가 echo 해준 시작 시각. */
startIso: string;
title: string;
}
/**
* 일정 생성. config 에 refresh token 이 있어야 함. access token 자동 갱신.
*
* 반환값:
* ok: true → CreatedEvent
* ok: false → 에러 메시지 (UI 표시용)
*/
export async function createCalendarEvent(
context: vscode.ExtensionContext,
input: CalendarEventInput,
): Promise<{ ok: true; event: CreatedEvent } | { ok: false; error: string }> {
const cfg = readCalendarConfig(context);
const tokenResult = await _getFreshAccessToken(context);
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
const body = _buildEventBody(input, cfg.defaultDurationMinutes ?? 60);
if (!body.ok) return { ok: false, error: body.error };
const calId = (cfg.calendarId || 'primary').trim() || 'primary';
const url = `${API_BASE}/calendars/${encodeURIComponent(calId)}/events`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${tokenResult.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body.event),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = json?.error?.message || `HTTP ${res.status}`;
return { ok: false, error: msg };
}
return {
ok: true,
event: {
id: json.id,
htmlLink: json.htmlLink,
startIso: json.start?.dateTime ?? json.start?.date ?? input.start,
title: input.title,
},
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/** Calendar API 요청 body 빌더 — 단위테스트 가능하도록 분리. */
export function _buildEventBody(
input: CalendarEventInput,
fallbackDurationMin: number,
): { ok: true; event: any } | { ok: false; error: string } {
if (!input.title || !input.title.trim()) return { ok: false, error: 'title 비어있음' };
if (!input.start || !input.start.trim()) return { ok: false, error: 'start 비어있음' };
if (input.allDay) {
// all-day: date 형식만 (YYYY-MM-DD). end 는 exclusive 라 다음 날.
const startDate = input.start.slice(0, 10);
const endDate = input.end ? input.end.slice(0, 10) : _addDaysDate(startDate, 1);
return {
ok: true,
event: {
summary: input.title.trim(),
description: input.description || undefined,
location: input.location || undefined,
start: { date: startDate },
end: { date: endDate },
reminders: { useDefault: true },
},
};
}
let endIso: string | undefined = input.end;
if (!endIso) {
const dur = (input.durationMinutes && input.durationMinutes > 0) ? input.durationMinutes : fallbackDurationMin;
const computed = _addMinutesIso(input.start, dur);
if (!computed) return { ok: false, error: `start 시각 형식 오류: ${input.start}` };
endIso = computed;
}
// Google Calendar 는 timezone 정보가 없으면 timeZone 필드 별도 필요.
// 'YYYY-MM-DDTHH:MM' 처럼 timezone 빠진 입력은 OS 로컬 timezone 으로 가정.
const hasOffset = /([+-]\d{2}:\d{2}|Z)$/.test(input.start);
const timeZone = hasOffset ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
return {
ok: true,
event: {
summary: input.title.trim(),
description: input.description || undefined,
location: input.location || undefined,
start: { dateTime: input.start, ...(timeZone ? { timeZone } : {}) },
end: { dateTime: endIso, ...(timeZone ? { timeZone } : {}) },
reminders: {
useDefault: false,
overrides: [
{ method: 'popup', minutes: 5 },
{ method: 'popup', minutes: 60 },
],
},
},
};
}
/** 'YYYY-MM-DDTHH:MM[:SS][±HH:MM|Z]' 에 분 더해 ISO 반환. 잘못된 형식이면 null. */
export function _addMinutesIso(startIso: string, minutes: number): string | null {
// 안전한 파싱: 명시적 정규식 → Date → ISO 재조립. timezone 정보 보존.
const m = startIso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(:\d{2})?([+-]\d{2}:\d{2}|Z)?$/);
if (!m) return null;
const [, base, sec, tz] = m;
const full = `${base}${sec ?? ':00'}${tz ?? ''}`;
const t = new Date(full);
if (Number.isNaN(t.getTime())) return null;
const out = new Date(t.getTime() + minutes * 60 * 1000);
// 원본이 timezone 정보 없는 로컬 시각이면 같은 포맷으로 돌려준다.
if (!tz) {
const yy = out.getFullYear();
const MM = String(out.getMonth() + 1).padStart(2, '0');
const dd = String(out.getDate()).padStart(2, '0');
const hh = String(out.getHours()).padStart(2, '0');
const mm = String(out.getMinutes()).padStart(2, '0');
const ss = String(out.getSeconds()).padStart(2, '0');
return `${yy}-${MM}-${dd}T${hh}:${mm}:${ss}`;
}
return out.toISOString();
}
/** 'YYYY-MM-DD' + N 일. all-day 일정 end 계산용. */
export function _addDaysDate(yyyymmdd: string, days: number): string {
const m = yyyymmdd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return yyyymmdd;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
d.setDate(d.getDate() + days);
const yy = d.getFullYear(), MM = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
return `${yy}-${MM}-${dd}`;
}
/**
* 현재 access token 이 유효하면 그대로, 아니면 refresh. config 의 만료 시각 사용.
* 갱신 후 만료 시각도 config 에 기록 — 다음 호출 때 불필요한 갱신 방지.
*
* Calendar / Sheets API 양쪽이 같은 token 을 공유한다 (scope 가 모두 같은 OAuth 에 포함).
* 그래서 `_` prefix 떼고 export — Sheets API client 가 직접 호출.
*/
export async function getFreshAccessToken(
context: vscode.ExtensionContext,
): Promise<{ ok: true; accessToken: string } | { ok: false; error: string }> {
const cfg = readCalendarConfig(context);
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
return { ok: false, error: 'Google OAuth 가 설정되지 않았습니다. "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 한 번 로그인하세요. (Calendar 와 Sheets 권한이 함께 발급됩니다.)' };
}
const now = Date.now();
if (cfg.accessToken && cfg.accessTokenExpiresAt && cfg.accessTokenExpiresAt > now) {
return { ok: true, accessToken: cfg.accessToken };
}
const r = await refreshAccessToken(cfg.clientId, cfg.clientSecret, cfg.refreshToken);
if (!r.ok) {
// 리프레시 토큰 자체가 만료/철회된 경우 — raw 구글 에러("Token has been
// expired or revoked.")만 보여주면 사용자가 복구 방법을 알 수 없다.
// 재연결 명령 + 흔한 근본 원인(OAuth 동의 화면 '테스트' 모드 = 7일 만료) 안내.
if (/expired|revoked|invalid_grant/i.test(r.error)) {
return {
ok: false,
error: 'Google OAuth 토큰이 만료/철회되었습니다 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 재연결하세요. '
+ "(자주 반복되면: Google Cloud Console → OAuth 동의 화면이 '테스트' 모드인지 확인 — 테스트 모드는 리프레시 토큰이 7일마다 만료되며, '앱 게시'로 프로덕션 전환 시 만료되지 않습니다.) "
+ `원인: ${r.error}`,
};
}
return { ok: false, error: r.error };
}
await writeCalendarConfig(context, { accessToken: r.accessToken, accessTokenExpiresAt: r.expiresAt });
return { ok: true, accessToken: r.accessToken };
}
// 내부 호출용 alias 유지 — 한 줄짜리라 비용 없음.
const _getFreshAccessToken = getFreshAccessToken;