Add Google Calendar integration and Claude docs

- New server/google_calendar.mjs wrapping googleapis v3 with auto token refresh
- Four new actions: calendar-list-events (auto-accept), calendar-create/update/delete-event (queue)
- bin/ccc-gcal-auth.mjs one-time OAuth2 consent flow helper
- config.example.json updated with google_calendar block
- server/config.mjs, index.mjs wired up following the same pattern as SMTP/mailer
- Bump version to 1.2.0
- Add CLAUDE.md and claude-info/ with architecture reference, feature plans, and contributing checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 15:33:36 +00:00
parent ba8c0701f8
commit 25891ece7e
12 changed files with 617 additions and 5 deletions

View File

@@ -120,4 +120,68 @@ export const actions = {
return { permissions: target_user ? all.filter(e => e.user === target_user) : all };
},
},
'calendar-list-events': {
description: 'List upcoming calendar events',
params: [
{ name: 'calendar_id', required: false, type: 'string' },
{ name: 'time_min', required: false, type: 'string' },
{ name: 'time_max', required: false, type: 'string' },
{ name: 'max_results', required: false, type: 'number' },
],
policy: 'auto-accept',
handler: async ({ calendar_id, time_min, time_max, max_results }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
const events = await calendar.list_events({ calendar_id, time_min, time_max, max_results });
return { events };
},
},
'calendar-create-event': {
description: 'Create a new calendar event',
params: [
{ name: 'summary', required: true, type: 'string' },
{ name: 'start', required: true, type: 'string' },
{ name: 'end', required: true, type: 'string' },
{ name: 'description', required: false, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
],
policy: 'queue',
handler: async ({ summary, start, end, description, calendar_id }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
const event = await calendar.create_event({ calendar_id, summary, start, end, description });
return { event };
},
},
'calendar-update-event': {
description: 'Update fields on an existing calendar event',
params: [
{ name: 'event_id', required: true, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
{ name: 'summary', required: false, type: 'string' },
{ name: 'start', required: false, type: 'string' },
{ name: 'end', required: false, type: 'string' },
{ name: 'description', required: false, type: 'string' },
],
policy: 'queue',
handler: async ({ event_id, calendar_id, summary, start, end, description }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
const event = await calendar.update_event({ calendar_id, event_id, summary, start, end, description });
return { event };
},
},
'calendar-delete-event': {
description: 'Delete a calendar event',
params: [
{ name: 'event_id', required: true, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
],
policy: 'queue',
handler: async ({ event_id, calendar_id }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
return calendar.delete_event({ calendar_id, event_id });
},
},
};

View File

@@ -46,10 +46,23 @@ export function load_config(file_path) {
? resolve(config_dir, parsed.mail_perms)
: null;
let google_calendar = null;
if (parsed.google_calendar) {
const { credentials, token } = parsed.google_calendar;
if (!credentials || !token) {
throw new Error("Config 'google_calendar' must have 'credentials' and 'token' fields");
}
google_calendar = {
credentials_path: resolve(config_dir, credentials),
token_path: resolve(config_dir, token),
};
}
return {
users: secrets.users,
smtp: parsed.smtp ?? null,
mail_perms_path,
google_calendar,
bind: parsed.bind ?? null,
port: parsed.port ?? null,
};

View File

@@ -0,0 +1,76 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { google } from 'googleapis';
// Returns a calendar client wrapping the Google Calendar API v3.
// cfg = { credentials_path, token_path }
export function create_calendar_client({ credentials_path, token_path }) {
const credentials = JSON.parse(readFileSync(credentials_path, 'utf8'));
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web;
const oauth2_client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
const token = JSON.parse(readFileSync(token_path, 'utf8'));
oauth2_client.setCredentials(token);
// Persist refreshed tokens automatically
oauth2_client.on('tokens', (new_tokens) => {
let current = {};
try {
current = JSON.parse(readFileSync(token_path, 'utf8'));
} catch (_) {}
const merged = { ...current, ...new_tokens };
writeFileSync(token_path, JSON.stringify(merged, null, '\t') + '\n', 'utf8');
});
const cal = google.calendar({ version: 'v3', auth: oauth2_client });
async function list_events({ calendar_id = 'primary', time_min, time_max, max_results = 10 } = {}) {
const res = await cal.events.list({
calendarId: calendar_id,
timeMin: time_min,
timeMax: time_max,
maxResults: max_results,
singleEvents: true,
orderBy: 'startTime',
});
return res.data.items ?? [];
}
async function create_event({ calendar_id = 'primary', summary, start, end, description }) {
const res = await cal.events.insert({
calendarId: calendar_id,
requestBody: {
summary,
description,
start: { dateTime: start },
end: { dateTime: end },
},
});
return res.data;
}
async function update_event({ calendar_id = 'primary', event_id, summary, start, end, description }) {
const patch = {};
if (summary !== undefined) { patch.summary = summary; }
if (description !== undefined) { patch.description = description; }
if (start !== undefined) { patch.start = { dateTime: start }; }
if (end !== undefined) { patch.end = { dateTime: end }; }
const res = await cal.events.patch({
calendarId: calendar_id,
eventId: event_id,
requestBody: patch,
});
return res.data;
}
async function delete_event({ calendar_id = 'primary', event_id }) {
await cal.events.delete({
calendarId: calendar_id,
eventId: event_id,
});
return { deleted: true, event_id };
}
return { list_events, create_event, update_event, delete_event };
}

View File

@@ -6,6 +6,7 @@ import { create_auth_middleware, check_can_approve } from './auth.mjs';
import { create_mailer } from './mailer.mjs';
import { exec as real_exec } from './helpers.mjs';
import { load_mail_perms } from './mail_perms.mjs';
import { create_calendar_client } from './google_calendar.mjs';
function get_arg(argv, flag) {
const i = argv.indexOf(flag);
@@ -29,7 +30,7 @@ try {
process.exit(1);
}
const { users, smtp, mail_perms_path } = cfg;
const { users, smtp, mail_perms_path, google_calendar: gcal_cfg } = cfg;
const PORT = process.env.CONDUIT_PORT || cfg.port || 3015;
const BIND = process.env.CONDUIT_BIND || cfg.bind || '127.0.0.1';
@@ -47,6 +48,16 @@ if (!mail_perms_path) {
const real_mailer_send = create_mailer(smtp);
let calendar = null;
if (gcal_cfg) {
try {
calendar = create_calendar_client(gcal_cfg);
console.log('Google Calendar client initialized');
} catch (err) {
console.error(`Warning: Failed to initialize Google Calendar client: ${err.message}`);
}
}
const app = express();
app.use(express.json({
verify: (req, _res, buf) => {
@@ -70,7 +81,7 @@ function make_ctx(caller) {
const mailer_send = DRY_RUN
? async (to, subject) => console.log(`[${ts()}] [DRY-RUN] send-mail: to=${to} subject=${JSON.stringify(subject)}`)
: real_mailer_send;
return { caller, users, mail_perm_store, exec, mailer_send };
return { caller, users, mail_perm_store, exec, mailer_send, calendar };
}
async function run_action(def, params, ctx) {