← back to Dw Activation Calendar

google-cal.js

240 lines

'use strict';
/*
 * google-cal.js — Google Calendar OAuth + Calendar v3 layer for the DW
 * Activation Calendar.  fetch-only, NO googleapis/SDK dependency (so it
 * needs zero npm installs and is immune to the symlinked node_modules).
 *
 * Pattern copied from ventura-corridor appointment-agent (oauth.ts +
 * calendar.ts), simplified to a SINGLE shared DW calendar rather than a
 * per-owner token table:
 *   - one OAuth connection (a single Google account owns/has-access-to the
 *     shared DW calendar), tokens persisted to a local JSON file;
 *   - read events (events.list) for a month;
 *   - create (events.insert), update (events.update / events.patch),
 *     delete (events.delete).
 *
 * GATED: this module never *initiates* the OAuth grant on its own. The
 * client-id/secret come from env (routed by the secrets skill — Steve-gated);
 * the consent click is Steve's. With no credential present, every read/write
 * returns a clean "not connected" signal so the UI degrades gracefully and
 * NEVER 500s.
 */
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const TOKEN_ENDPOINT  = 'https://oauth2.googleapis.com/token';
const AUTH_ENDPOINT   = 'https://accounts.google.com/o/oauth2/v2/auth';
const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
const CAL_BASE        = 'https://www.googleapis.com/calendar/v3';

const SCOPES = [
  'openid', 'email', 'profile',
  'https://www.googleapis.com/auth/calendar.events',
  'https://www.googleapis.com/auth/calendar.readonly',
];

// Which calendar the modal edits. Default = the connected account's "primary".
// Override with GOOGLE_DW_CALENDAR_ID to point at a dedicated shared calendar.
const CALENDAR_ID = process.env.GOOGLE_DW_CALENDAR_ID || 'primary';

// Token + state stored outside git (see .gitignore: *.local.json).
const TOKEN_FILE = process.env.GOOGLE_CAL_TOKEN_FILE
  || path.join(__dirname, '.google-tokens.local.json');
// In-memory single-use state nonces for the OAuth handshake.
const _states = new Map(); // state -> createdAt

function getOAuthConfig() {
  const clientId     = process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID || '';
  const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET || '';
  const redirectUri  = process.env.GOOGLE_OAUTH_REDIRECT_URI
    || `http://127.0.0.1:${process.env.PORT || 9765}/api/oauth/callback`;
  return { clientId, clientSecret, redirectUri };
}

function isOAuthConfigured() {
  const { clientId, clientSecret } = getOAuthConfig();
  return Boolean(clientId && clientSecret);
}

// --- token persistence ------------------------------------------------------
function _readTokens() {
  try { return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')); }
  catch { return null; }
}
function _writeTokens(t) {
  fs.writeFileSync(TOKEN_FILE, JSON.stringify(t, null, 2), { mode: 0o600 });
}
function isConnected() {
  const t = _readTokens();
  return Boolean(t && (t.access_token || t.refresh_token));
}

// --- OAuth handshake --------------------------------------------------------
function buildAuthUrl() {
  const { clientId, redirectUri } = getOAuthConfig();
  const state = crypto.randomBytes(16).toString('hex');
  _states.set(state, Date.now());
  // prune nonces older than 10 min
  for (const [s, at] of _states) if (Date.now() - at > 600_000) _states.delete(s);
  const params = new URLSearchParams({
    client_id: clientId, redirect_uri: redirectUri, response_type: 'code',
    scope: SCOPES.join(' '), access_type: 'offline', prompt: 'consent', state,
  });
  return `${AUTH_ENDPOINT}?${params.toString()}`;
}

async function exchangeCodeAndStore(code, state) {
  if (!_states.has(state)) throw new Error('Unknown or expired OAuth state');
  _states.delete(state);
  const { clientId, clientSecret, redirectUri } = getOAuthConfig();
  const body = new URLSearchParams({
    code, client_id: clientId, client_secret: clientSecret,
    redirect_uri: redirectUri, grant_type: 'authorization_code',
  });
  const tok = await fetch(TOKEN_ENDPOINT, {
    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });
  if (!tok.ok) throw new Error(`Google token exchange failed: ${tok.status} ${await tok.text()}`);
  const j = await tok.json();
  _writeTokens({
    access_token:  j.access_token,
    refresh_token: j.refresh_token || (_readTokens() || {}).refresh_token || null,
    expires_at:    Date.now() + (Number(j.expires_in || 3600) * 1000),
    scope:         j.scope || null,
    token_type:    j.token_type || 'Bearer',
  });
  return true;
}

/** Valid access token, refreshing if needed; null if not connected. */
async function getValidAccessToken() {
  const t = _readTokens();
  if (!t) return null;
  if (t.access_token && t.expires_at - Date.now() > 120_000) return t.access_token;
  if (!t.refresh_token) return t.access_token || null;
  const { clientId, clientSecret } = getOAuthConfig();
  const body = new URLSearchParams({
    client_id: clientId, client_secret: clientSecret,
    grant_type: 'refresh_token', refresh_token: t.refresh_token,
  });
  const tok = await fetch(TOKEN_ENDPOINT, {
    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });
  if (!tok.ok) { console.error('[google-cal] refresh failed', tok.status, await tok.text()); return null; }
  const j = await tok.json();
  _writeTokens({ ...t, access_token: j.access_token, expires_at: Date.now() + (Number(j.expires_in || 3600) * 1000) });
  return j.access_token;
}

async function revoke() {
  const tok = await getValidAccessToken();
  if (tok) {
    try { await fetch(`${REVOKE_ENDPOINT}?token=${encodeURIComponent(tok)}`, { method: 'POST' }); }
    catch (e) { console.error('[google-cal] revoke error (continuing):', e); }
  }
  try { fs.unlinkSync(TOKEN_FILE); } catch {}
}

// --- Calendar v3 CRUD -------------------------------------------------------
function _calUrl(suffix = '') {
  return `${CAL_BASE}/calendars/${encodeURIComponent(CALENDAR_ID)}${suffix}`;
}

/** Status object the UI uses to render the "connected / not connected" banner. */
function status() {
  return {
    oauth_configured: isOAuthConfigured(),
    connected:        isConnected(),
    calendar_id:      CALENDAR_ID,
  };
}

/** events.list for a window. Returns [] (never throws) when not connected. */
async function listEvents(timeMinISO, timeMaxISO) {
  const token = await getValidAccessToken();
  if (!token) return { connected: false, events: [] };
  const qs = new URLSearchParams({
    timeMin: timeMinISO, timeMax: timeMaxISO,
    singleEvents: 'true', orderBy: 'startTime', maxResults: '2500',
  });
  const r = await fetch(`${_calUrl('/events')}?${qs}`, {
    headers: { 'Authorization': `Bearer ${token}` },
  });
  if (!r.ok) { console.error('[google-cal] events.list failed', r.status, await r.text()); return { connected: true, events: [], error: r.status }; }
  const j = await r.json();
  return { connected: true, events: (j.items || []).map(_normalize) };
}

function _normalize(e) {
  return {
    id: e.id,
    summary: e.summary || '(no title)',
    description: e.description || '',
    location: e.location || '',
    start: e.start?.dateTime || e.start?.date || null,
    end:   e.end?.dateTime   || e.end?.date   || null,
    allDay: Boolean(e.start?.date && !e.start?.dateTime),
    htmlLink: e.htmlLink || null,
    created: e.created || null,
    updated: e.updated || null,
    colorId: e.colorId || null,
  };
}

function _toGoogleBody(p) {
  const body = { summary: p.summary, description: p.description || '', location: p.location || '' };
  if (p.allDay) {
    body.start = { date: (p.start || '').slice(0, 10) };
    body.end   = { date: (p.end || p.start || '').slice(0, 10) };
  } else {
    body.start = { dateTime: p.start };
    body.end   = { dateTime: p.end };
  }
  if (p.colorId) body.colorId = String(p.colorId);
  return body;
}

async function insertEvent(p) {
  const token = await getValidAccessToken();
  if (!token) return { ok: false, reason: 'not_connected' };
  const r = await fetch(`${_calUrl('/events')}?sendUpdates=none`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(_toGoogleBody(p)),
  });
  if (!r.ok) return { ok: false, reason: 'google_error', status: r.status, detail: await r.text() };
  return { ok: true, event: _normalize(await r.json()) };
}

async function updateEvent(eventId, p) {
  const token = await getValidAccessToken();
  if (!token) return { ok: false, reason: 'not_connected' };
  // patch (partial) is safer than full update — only send changed fields.
  const r = await fetch(`${_calUrl('/events/' + encodeURIComponent(eventId))}?sendUpdates=none`, {
    method: 'PATCH',
    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(_toGoogleBody(p)),
  });
  if (!r.ok) return { ok: false, reason: 'google_error', status: r.status, detail: await r.text() };
  return { ok: true, event: _normalize(await r.json()) };
}

async function deleteEvent(eventId) {
  const token = await getValidAccessToken();
  if (!token) return { ok: false, reason: 'not_connected' };
  const r = await fetch(`${_calUrl('/events/' + encodeURIComponent(eventId))}?sendUpdates=none`, {
    method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` },
  });
  return { ok: r.ok || r.status === 410 }; // 410 = already gone
}

module.exports = {
  SCOPES, CALENDAR_ID,
  getOAuthConfig, isOAuthConfigured, isConnected, status,
  buildAuthUrl, exchangeCodeAndStore, getValidAccessToken, revoke,
  listEvents, insertEvent, updateEvent, deleteEvent,
};