UNPKG

@lautmaler/crm-connectors

Version:

Provides connectors to various CRM systems and calendar services.

509 lines 18.6 kB
import axios from "axios"; import winston from "winston"; import fs from "fs"; import { google } from "googleapis"; import { OAuth2Client } from "google-auth-library"; // Environment variables const GOOGLE_CALENDAR_OWNER = process.env.GOOGLE_CALENDAR_OWNER; const SIPGATE_TOKEN_ID = process.env.SIPGATE_TOKEN_ID; const SIPGATE_TOKEN = process.env.SIPGATE_TOKEN; const GOOGLE_APPLICATION_CREDENTIALS = process.env.GOOGLE_APPLICATION_CREDENTIALS; export class GoogleCalendarBackend { // Constants SIPGATE_BASE_URL = 'https://api.sipgate.com/v2'; DEFAULT_TIMEZONE = 'Europe/Berlin'; APPOINTMENT_DURATION_MS = 60 * 60 * 1000; // 1 hour in milliseconds DEFAULT_DELEGATION_EMAIL = "konrad@lautmaler.de"; // Services logger = winston.createLogger(); googleAuth = new OAuth2Client(); calendarService = google.calendar({ version: "v3" }); meetService = google.meet({ version: "v2" }); // Configuration calendarOwnerId; userDelegationEmail; googleMeetUrl = "https://meet.google.com"; constructor() { this.userDelegationEmail = this.DEFAULT_DELEGATION_EMAIL; this.calendarOwnerId = GOOGLE_CALENDAR_OWNER || ""; this.googleAuth = new OAuth2Client(); // Initialize services this.initializeLogger(); this.initializeGoogleServices(); } /** * Initialize the logger */ initializeLogger() { this.logger = winston.createLogger({ level: "info", format: winston.format.json(), defaultMeta: { service: "crm-service" }, transports: [ new winston.transports.Console({ format: winston.format.simple(), }), ], }); } /** * Initialize Google services if not in test environment */ initializeGoogleServices() { // Skip initialization in test environment if (process.env.APP_ENV === "test") { this.logger.info("Google services not initialized in test environment"); return; } try { this.initializeGoogleAuth(); } catch (error) { this.logger.error("Error initializing Google services:", error); } } /** * Authenticate with Google services */ async initializeGoogleAuth() { const credentials = this.parseCredentials(); this.setupCalendarService(credentials); this.setupMeetService(credentials); } /** * Parse the Google service account credentials */ parseCredentials() { if (!GOOGLE_APPLICATION_CREDENTIALS) { throw new Error("Service account key path is not specified."); } try { const fileContents = fs.readFileSync(GOOGLE_APPLICATION_CREDENTIALS, "utf8"); const credentials = JSON.parse(fileContents); if (!credentials.client_email || !credentials.private_key) { throw new Error("Invalid credentials format: missing client_email or private_key"); } return credentials; } catch (error) { this.logger.error("Error parsing credentials:", error); throw new Error("Failed to parse service account credentials"); } } /** * Set up the Google Calendar service */ setupCalendarService(credentials) { const auth = new google.auth.JWT({ email: credentials.client_email, key: credentials.private_key, scopes: [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events", ], subject: this.userDelegationEmail, }); this.googleAuth = auth; this.calendarService = google.calendar({ version: "v3", auth: this.googleAuth, }); // Set up ACL rules this.setupCalendarAcl(); this.logger.info("Google Calendar service initialized"); } /** * Set up ACL rules for calendar sharing */ async setupCalendarAcl() { try { const aclRules = [ { role: "reader", scope: { type: "default", }, }, ]; for (const rule of aclRules) { await this.calendarService.acl.insert({ calendarId: "primary", requestBody: rule, }); } } catch (error) { this.logger.error("Error setting up calendar ACL:", error); } } /** * Set up the Google Meet service */ setupMeetService(credentials) { const meetServiceAuth = new google.auth.JWT({ email: credentials.client_email, key: credentials.private_key, scopes: [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/meetings.space.created" ], subject: this.userDelegationEmail, }); this.meetService = google.meet({ version: "v2", auth: meetServiceAuth, }); } /** * Fetch available time slots based on calendar availability */ async fetchAvailableSlots(timestamp, attendees) { const timeMin = new Date(timestamp).toISOString(); // set timeMax to 6 hours from the timestamp const timeMax = new Date(new Date(timestamp).getTime() + 6 * 60 * 60 * 1000).toISOString(); if (!attendees || attendees.length === 0) { attendees = []; } const payload = { timeMin, timeMax, timeZone: this.DEFAULT_TIMEZONE, items: [{ id: this.calendarOwnerId }].concat(attendees.map((email) => ({ id: email }))), }; const busySlots = await this.freeBusyApiCall(payload); const totalSlots = this.generateAvailableSlots(timeMin, timeMax, busySlots); // return 3 slots return totalSlots.slice(0, 3); } /** * Call the FreeBusy API to get busy time slots */ async freeBusyApiCall(payload) { const freeBusyEndpoint = "https://www.googleapis.com/calendar/v3/freeBusy"; // Ensure we have an access token const accessToken = await this.googleAuth.getAccessToken(); if (!accessToken) { throw new Error("Failed to get access token"); } // this.logger.info(`Access token: ${accessToken.token}`); try { const response = await axios.post(freeBusyEndpoint, payload, { headers: { Authorization: `Bearer ${accessToken.token}`, "Content-Type": "application/json", }, }); const freeBusyResponse = response.data; this.logger.info("FreeBusy API response received successfully"); return freeBusyResponse.calendars[this.calendarOwnerId].busy; } catch (error) { this.logger.error("Error fetching available slots:", error.response?.data || error); throw new Error("Failed to fetch available slots"); } } /** * Generate available time slots based on busy times */ generateAvailableSlots(timeMin, timeMax, busySlots) { const availableSlots = []; let currentSlot = new Date(timeMin); const endTime = new Date(timeMax); while (currentSlot < endTime) { const slotEnd = new Date(currentSlot.getTime() + this.APPOINTMENT_DURATION_MS); const isSlotBusy = busySlots.some((busySlot) => busySlot.start <= slotEnd.toISOString() && busySlot.end >= currentSlot.toISOString()); if (!isSlotBusy) { availableSlots.push(currentSlot.toISOString()); } currentSlot = slotEnd; } return availableSlots; } /** * Fetch available appointment types */ async fetchAppointmentTypes() { return ["Consultation", "Meeting", "Demo"]; } /** * Book a new appointment */ async bookAppointment(appointment) { try { return this.createEvent(appointment); } catch (error) { this.logger.error("Error booking appointment:", error); throw new Error("Failed to book appointment"); } } /** * Modify an existing appointment */ async modifyAppointment(id, updatedInfo) { // Fetch the original appointment const originalAppointment = await this.findAppointmentById(id); if (!originalAppointment) { throw new Error("Appointment not found"); } // Merge original and updated info const updatedEmployeeEmail = updatedInfo.employeeEmail ?? originalAppointment.employeeEmail; const updatedContactEmail = updatedInfo.contactEmail ?? originalAppointment.contactEmail; const updatedTimestamp = updatedInfo.timestamp ?? originalAppointment.timestamp; if (!updatedEmployeeEmail || !updatedContactEmail) { throw new Error("Email cannot be undefined"); } // Create updated event data const requestBody = { summary: "Meeting", start: { dateTime: new Date(updatedTimestamp).toISOString(), timeZone: "UTC", }, end: { dateTime: new Date(new Date(updatedTimestamp).getTime() + this.APPOINTMENT_DURATION_MS).toISOString(), timeZone: "UTC", }, attendees: [ { email: updatedEmployeeEmail }, { email: updatedContactEmail }, ], conferenceData: { createRequest: { requestId: updatedTimestamp, conferenceSolutionKey: { type: "hangoutsMeet" }, }, }, }; // Update the event in Google Calendar await this.calendarService.events.update({ calendarId: "primary", eventId: id, requestBody: requestBody, }); return { ...originalAppointment, ...updatedInfo }; } /** * Find an appointment by contact name (email) */ async findAppointmentByContactName(name) { const response = await this.calendarService.events.list({ calendarId: "primary", }); const events = response.data.items ?? []; const appointments = events .filter((event) => event.attendees?.find((attendee) => attendee.email === name)) .map((event) => this.mapEventToAppointment(event)); if (appointments.length === 0) { throw new Error("No appointments found for this contact"); } return appointments[0]; } /** * Find an appointment by timestamp */ async findAppointmentByTimestamp(timestamp) { throw new Error("Method not implemented."); } /** * Find an appointment by ID */ async findAppointmentById(id) { try { const response = await this.calendarService.events.get({ calendarId: "primary", eventId: id, }); const event = response.data; return this.mapEventToAppointment(event); } catch (error) { this.logger.error("Error finding appointment by ID:", error); return null; } } /** * Map a Google Calendar event to an Appointment object */ mapEventToAppointment(event) { if (!event.id || !event.start?.dateTime) { throw new Error("Invalid event data"); } const attendees = event.attendees || []; if (attendees.length < 2) { throw new Error("Event has insufficient attendees"); } return { id: event.id, type: "Meeting", timestamp: event.start.dateTime, contactEmail: attendees[0]?.email || "", contactName: attendees[0]?.displayName || attendees[0]?.email || "", employeeEmail: attendees[1]?.email || "", status: "Scheduled", }; } /** * Create a Google Calendar event */ async createEvent(appointment) { return this.createConference(appointment); } /** * Create a conference event with Google Meet */ async createConference(appointment) { try { let attendeeEmails = [{ email: appointment.employeeEmail }]; if (appointment.contactEmail) { attendeeEmails.push({ email: appointment.contactEmail }); } // Create the calendar event with conference data const startTime = new Date(appointment.timestamp).toISOString(); const endTime = new Date(new Date(appointment.timestamp).getTime() + this.APPOINTMENT_DURATION_MS).toISOString(); // @ts-expect-error overload conflict const conference = await this.calendarService.events.insert({ calendarId: "primary", anyoneCanAddSelf: true, visibility: "public", description: this.googleMeetUrl, requestBody: { summary: `${appointment.employeeName || ""} & ${appointment.contactName || ""}`, start: { dateTime: startTime, timeZone: "UTC", }, end: { dateTime: endTime, timeZone: "UTC", }, attendees: attendeeEmails, }, conferenceDataVersion: 1, sendUpdates: "all", }); this.logger.info("Event created: %s", conference.data.htmlLink); // Extract event ID from HTML link const htmlLink = conference.data.htmlLink; const eventId = htmlLink.split("?eid=")[1]; return eventId; } catch (error) { this.logger.error("Error creating conference event:", error); throw new Error("Failed to create conference event"); } } /** * Create an event and send an SMS notification */ async createEventToSms(appointment) { try { // Create Google Meet space const conferenceData = await this.meetService.spaces.create({ requestBody: { config: { accessType: "OPEN", entryPointAccess: "ALL", } } }); const meetingUrl = conferenceData.data.meetingUri || ""; this.googleMeetUrl = meetingUrl; // Format SMS message const messageContent = this.formatSmsMessage(appointment, meetingUrl); // Validate phone number if (!appointment.contactPhone) { throw new Error("Contact phone number is missing"); } // Send SMS await this.sendSipgateSms(messageContent, appointment.contactPhone); return "success"; } catch (error) { this.logger.error("Error creating event and sending SMS:", error); throw new Error("Failed to create event and send SMS"); } } /** * Create a calendar event and send an SMS invitation */ async createCalendarEventToSms(appointment) { try { // Create the event const eventId = await this.createEvent(appointment); this.logger.info(`Event created with ID: ${eventId}`); // Generate invitation message const message = this.formatSmsMessage(appointment, this.googleMeetUrl); // Validate phone number if (!appointment.contactPhone) { throw new Error("Contact phone number is missing"); } // Send SMS await this.sendSipgateSms(message, appointment.contactPhone); this.logger.info(`SMS sent to ${appointment.contactPhone}`); return "success"; } catch (error) { this.logger.error("Error creating calendar event and sending SMS:", error); throw new Error("Failed to create calendar event and send SMS"); } } /** * Format an SMS message for appointment notification */ formatSmsMessage(appointment, meetSpaceUrl) { const formattedDate = new Date(appointment.timestamp).toLocaleString("de-DE", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "numeric", }); return [ `Hallo ${appointment.contactName},`, `Vielen Dank für Ihren Anruf.`, `Sie haben einen Termin mit ${appointment.employeeName}`, `am ${formattedDate}.`, `Dem Meeting können Sie über folgenden Link beitreten:`, `${meetSpaceUrl}`, `Wir freuen uns auf Sie!`, `Die Lautmaler GmbH` ].join(' '); } /** * Send an SMS via Sipgate API */ async sendSipgateSms(message, recipient) { this.logger.info('SIPGATE:: verifying credentials'); if (!SIPGATE_TOKEN || !SIPGATE_TOKEN_ID) { throw new Error('ERROR: SIPGATE CREDENTIALS MISSING'); } const headers = { 'Content-Type': 'application/json', }; const requestBody = { smsId: 's9', recipient: recipient, message: message, }; try { this.logger.info('SIPGATE:: sending SMS'); const response = await axios.post(`${this.SIPGATE_BASE_URL}/sessions/sms`, requestBody, { headers: headers, auth: { username: SIPGATE_TOKEN_ID, password: SIPGATE_TOKEN, }, }); return response.data; } catch (error) { this.logger.error(`SIPGATE:: error ${error.message}`); throw new Error(`Failed to send SMS: ${error.message}`); } } } export default GoogleCalendarBackend; //# sourceMappingURL=GoogleCalendarBackend.js.map