mcp-memory-libsql
by spences10
Verified
import { google, calendar_v3 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import path from 'path';
import { getAccountManager } from '../accounts/index.js';
import {
GetEventsParams,
CreateEventParams,
EventResponse,
CreateEventResponse,
CalendarError,
CalendarModuleConfig,
ManageEventParams,
ManageEventResponse,
CalendarAttachment
} from './types.js';
import { AttachmentService } from '../attachments/service.js';
import { DriveService } from '../drive/service.js';
import { ATTACHMENT_FOLDERS, AttachmentSource, AttachmentMetadata } from '../attachments/types.js';
type CalendarEvent = calendar_v3.Schema$Event;
type GoogleEventAttachment = calendar_v3.Schema$EventAttachment;
// Convert Google Calendar attachment to our format
function convertToCalendarAttachment(attachment: GoogleEventAttachment): CalendarAttachment {
return {
content: attachment.fileUrl || '', // Use fileUrl as content for now
title: attachment.title || 'untitled',
mimeType: attachment.mimeType || 'application/octet-stream',
size: 0 // Size not available from Calendar API
};
}
// Convert our attachment format to Google Calendar format
function convertToGoogleAttachment(attachment: CalendarAttachment): GoogleEventAttachment {
return {
title: attachment.title,
mimeType: attachment.mimeType,
fileUrl: attachment.content // Store content in fileUrl
};
}
/**
* Google Calendar Service Implementation
*/
export class CalendarService {
private oauth2Client!: OAuth2Client;
private attachmentService?: AttachmentService;
private driveService?: DriveService;
private initialized = false;
private config?: CalendarModuleConfig;
constructor(config?: CalendarModuleConfig) {
this.config = config;
}
/**
* Initialize the Calendar service and all dependencies
*/
public async initialize(): Promise<void> {
try {
const accountManager = getAccountManager();
this.oauth2Client = await accountManager.getAuthClient();
this.driveService = new DriveService();
await this.driveService.ensureInitialized();
this.attachmentService = AttachmentService.getInstance({
maxSizeBytes: this.config?.maxAttachmentSize,
allowedMimeTypes: this.config?.allowedAttachmentTypes
});
this.initialized = true;
} catch (error) {
throw new CalendarError(
'Failed to initialize Calendar service',
'INIT_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
/**
* Ensure the Calendar service is initialized
*/
public async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
/**
* Check if the service is initialized
*/
private checkInitialized(): void {
if (!this.initialized) {
throw new CalendarError(
'Calendar service not initialized',
'INIT_ERROR',
'Please ensure the service is initialized before use'
);
}
}
/**
* Get an authenticated Google Calendar API client
*/
private async getCalendarClient(email: string) {
if (!this.initialized) {
await this.initialize();
}
const accountManager = getAccountManager();
try {
const tokenStatus = await accountManager.validateToken(email);
if (!tokenStatus.valid || !tokenStatus.token) {
throw new CalendarError(
'Calendar authentication required',
'AUTH_REQUIRED',
'Please authenticate to access calendar'
);
}
this.oauth2Client.setCredentials(tokenStatus.token);
return google.calendar({ version: 'v3', auth: this.oauth2Client });
} catch (error) {
if (error instanceof CalendarError) {
throw error;
}
throw new CalendarError(
'Failed to initialize Calendar client',
'AUTH_ERROR',
'Please try again or contact support if the issue persists'
);
}
}
/**
* Handle Calendar operations with automatic token refresh on 401/403
*/
private async handleCalendarOperation<T>(email: string, operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error: any) {
if (error.code === 401 || error.code === 403) {
const accountManager = getAccountManager();
const tokenStatus = await accountManager.validateToken(email);
if (tokenStatus.valid && tokenStatus.token) {
this.oauth2Client.setCredentials(tokenStatus.token);
return await operation();
}
}
throw error;
}
}
/**
* Process event attachments directly like Gmail
*/
private async processEventAttachments(
email: string,
attachments: GoogleEventAttachment[]
): Promise<AttachmentMetadata[]> {
if (!this.attachmentService) {
throw new CalendarError(
'Calendar service not initialized',
'SERVICE_ERROR',
'Please ensure the service is initialized before processing attachments'
);
}
const processedAttachments: AttachmentMetadata[] = [];
for (const googleAttachment of attachments) {
// Convert Google attachment to our format
const attachment = convertToCalendarAttachment(googleAttachment);
const result = await this.attachmentService.processAttachment(
email,
{
content: attachment.content,
metadata: {
name: attachment.title,
mimeType: attachment.mimeType,
size: attachment.size
}
},
ATTACHMENT_FOLDERS.EVENT_FILES
);
if (result.success && result.attachment) {
processedAttachments.push(result.attachment);
}
}
return processedAttachments;
}
/**
* Map Calendar event to EventResponse
*/
private async mapEventResponse(email: string, event: CalendarEvent): Promise<EventResponse> {
// Process attachments if present
let attachments: { name: string }[] | undefined;
if (event.attachments && event.attachments.length > 0) {
// First process and store full metadata
const processedAttachments = await this.processEventAttachments(email, event.attachments);
// Then return simplified format for AI
attachments = processedAttachments.map(att => ({
name: att.name
}));
}
return {
id: event.id!,
summary: event.summary || '',
description: event.description || undefined,
start: {
dateTime: event.start?.dateTime || event.start?.date || '',
timeZone: event.start?.timeZone || 'UTC'
},
end: {
dateTime: event.end?.dateTime || event.end?.date || '',
timeZone: event.end?.timeZone || 'UTC'
},
attendees: event.attendees?.map(attendee => ({
email: attendee.email!,
responseStatus: attendee.responseStatus || undefined
})),
organizer: event.organizer ? {
email: event.organizer.email!,
self: event.organizer.self || false
} : undefined,
attachments: attachments?.length ? attachments : undefined
};
}
/**
* Retrieve calendar events with optional filtering
*/
async getEvents({ email, query, maxResults = 10, timeMin, timeMax }: GetEventsParams): Promise<EventResponse[]> {
const calendar = await this.getCalendarClient(email);
return this.handleCalendarOperation(email, async () => {
const params: calendar_v3.Params$Resource$Events$List = {
calendarId: 'primary',
maxResults,
singleEvents: true,
orderBy: 'startTime'
};
if (query) {
params.q = query;
}
if (timeMin) {
try {
const date = new Date(timeMin);
if (isNaN(date.getTime())) {
throw new Error('Invalid date');
}
params.timeMin = date.toISOString();
} catch (error) {
throw new CalendarError(
'Invalid date format',
'INVALID_DATE',
'Please provide dates in ISO format or YYYY-MM-DD format'
);
}
}
if (timeMax) {
try {
const date = new Date(timeMax);
if (isNaN(date.getTime())) {
throw new Error('Invalid date');
}
params.timeMax = date.toISOString();
} catch (error) {
throw new CalendarError(
'Invalid date format',
'INVALID_DATE',
'Please provide dates in ISO format or YYYY-MM-DD format'
);
}
}
const { data } = await calendar.events.list(params);
if (!data.items || data.items.length === 0) {
return [];
}
return Promise.all(data.items.map(event => this.mapEventResponse(email, event)));
});
}
/**
* Retrieve a single calendar event by ID
*/
async getEvent(email: string, eventId: string): Promise<EventResponse> {
const calendar = await this.getCalendarClient(email);
return this.handleCalendarOperation(email, async () => {
const { data: event } = await calendar.events.get({
calendarId: 'primary',
eventId
});
if (!event) {
throw new CalendarError(
'Event not found',
'NOT_FOUND',
`No event found with ID: ${eventId}`
);
}
return this.mapEventResponse(email, event);
});
}
/**
* Create a new calendar event
*/
async createEvent({ email, summary, description, start, end, attendees, attachments = [] }: CreateEventParams): Promise<CreateEventResponse> {
const calendar = await this.getCalendarClient(email);
return this.handleCalendarOperation(email, async () => {
// Process attachments first
const processedAttachments: GoogleEventAttachment[] = [];
for (const attachment of attachments) {
const source: AttachmentSource = {
content: attachment.content || '',
metadata: {
name: attachment.name,
mimeType: attachment.mimeType,
size: attachment.size
}
};
if (!this.attachmentService) {
throw new CalendarError(
'Calendar service not initialized',
'SERVICE_ERROR',
'Please ensure the service is initialized before processing attachments'
);
}
const result = await this.attachmentService.processAttachment(
email,
source,
ATTACHMENT_FOLDERS.EVENT_FILES
);
if (result.success && result.attachment) {
// Convert back to Google format
processedAttachments.push(convertToGoogleAttachment({
content: result.attachment.id, // Use ID as content
title: result.attachment.name,
mimeType: result.attachment.mimeType,
size: result.attachment.size
}));
}
}
const eventData: calendar_v3.Schema$Event = {
summary,
description,
start,
end,
attendees: attendees?.map(attendee => ({ email: attendee.email })),
attachments: processedAttachments.length > 0 ? processedAttachments : undefined
};
const { data: event } = await calendar.events.insert({
calendarId: 'primary',
requestBody: eventData,
sendUpdates: 'all'
});
if (!event.id || !event.summary) {
throw new CalendarError(
'Failed to create event',
'CREATE_ERROR',
'Event creation response was incomplete'
);
}
// Convert processed attachments to AttachmentMetadata format
const attachmentMetadata = processedAttachments.length > 0 ?
processedAttachments.map(a => ({
id: a.fileId!,
name: a.title!,
mimeType: a.mimeType!,
size: 0, // Size not available from Calendar API
path: path.join(this.attachmentService!.getAttachmentPath(ATTACHMENT_FOLDERS.EVENT_FILES), `${a.fileId}_${a.title}`)
})) :
undefined;
return {
id: event.id,
summary: event.summary,
htmlLink: event.htmlLink || '',
attachments: attachmentMetadata
};
});
}
/**
* Manage calendar event responses and updates
*/
async manageEvent({ email, eventId, action, comment, newTimes }: ManageEventParams): Promise<ManageEventResponse> {
const calendar = await this.getCalendarClient(email);
return this.handleCalendarOperation(email, async () => {
const event = await calendar.events.get({
calendarId: 'primary',
eventId
});
if (!event.data) {
throw new CalendarError(
'Event not found',
'NOT_FOUND',
`No event found with ID: ${eventId}`
);
}
switch (action) {
case 'accept':
case 'decline':
case 'tentative': {
const responseStatus = action === 'accept' ? 'accepted' :
action === 'decline' ? 'declined' :
'tentative';
const { data: updatedEvent } = await calendar.events.patch({
calendarId: 'primary',
eventId,
sendUpdates: 'all',
requestBody: {
attendees: [
{
email,
responseStatus
}
]
}
});
return {
success: true,
eventId,
action,
status: 'completed',
htmlLink: updatedEvent.htmlLink || undefined
};
}
case 'propose_new_time': {
if (!newTimes || newTimes.length === 0) {
throw new CalendarError(
'No proposed times provided',
'INVALID_REQUEST',
'Please provide at least one proposed time'
);
}
const counterProposal = await calendar.events.insert({
calendarId: 'primary',
requestBody: {
summary: `Counter-proposal: ${event.data.summary}`,
description: `Counter-proposal for original event.\n\nComment: ${comment || 'No comment provided'}\n\nOriginal event: ${event.data.htmlLink}`,
start: newTimes[0].start,
end: newTimes[0].end,
attendees: event.data.attendees
}
});
return {
success: true,
eventId,
action,
status: 'proposed',
htmlLink: counterProposal.data.htmlLink || undefined,
proposedTimes: newTimes.map(time => ({
start: { dateTime: time.start.dateTime, timeZone: time.start.timeZone || 'UTC' },
end: { dateTime: time.end.dateTime, timeZone: time.end.timeZone || 'UTC' }
}))
};
}
case 'update_time': {
if (!newTimes || newTimes.length === 0) {
throw new CalendarError(
'No new time provided',
'INVALID_REQUEST',
'Please provide the new time for the event'
);
}
const { data: updatedEvent } = await calendar.events.patch({
calendarId: 'primary',
eventId,
requestBody: {
start: newTimes[0].start,
end: newTimes[0].end
},
sendUpdates: 'all'
});
return {
success: true,
eventId,
action,
status: 'updated',
htmlLink: updatedEvent.htmlLink || undefined
};
}
default:
throw new CalendarError(
'Supported actions are: accept, decline, tentative, propose_new_time, update_time',
'INVALID_ACTION',
'Invalid action'
);
}
});
}
/**
* Delete a calendar event
*/
async deleteEvent(email: string, eventId: string, sendUpdates: 'all' | 'externalOnly' | 'none' = 'all'): Promise<void> {
const calendar = await this.getCalendarClient(email);
return this.handleCalendarOperation(email, async () => {
await calendar.events.delete({
calendarId: 'primary',
eventId,
sendUpdates
});
});
}
}