b540923890
/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>
219 lines
9.5 KiB
TypeScript
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;
|