GHL SaaS Backend

This commit is contained in:
Cole Medin
2024-08-21 16:36:57 -05:00
parent 3be0c4348b
commit d2fdcb9c3b
15 changed files with 10493 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
{
"projects": {
"default": "ghl-saas-backend"
}
}

68
gohighlevel-saas-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
firebase-debug.log*
firebase-debug.*.log*
.runtimeconfig.json
# Firebase cache
.firebase/
# Firebase config
# Uncomment this if you'd like others to create their own Firebase project.
# For a team working on the same Firebase project(s), it is recommended to leave
# it commented so all members can deploy to the same project(s) in .firebaserc.
# .firebaserc
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env

View File

@@ -0,0 +1,22 @@
{
"functions": [
{
"source": "functions",
"codebase": "default",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log"
]
}
],
"hosting": {
"public": "public",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
]
}
}

View File

@@ -0,0 +1,4 @@
{
"indexes": [],
"fieldOverrides": []
}

View File

@@ -0,0 +1,9 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
# TypeScript v1 declaration files
typings/
# Node.js dependency directory
node_modules/

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"compileOnSave": true,
"include": [
"src"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"name": "functions",
"scripts": {
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "18"
},
"main": "src/index.js",
"dependencies": {
"@anthropic-ai/sdk": "^0.19.1",
"@google-cloud/logging": "^11.0.0",
"@google/generative-ai": "^0.5.0",
"@langchain/langgraph": "^0.0.31",
"@langchain/openai": "^0.2.4",
"@langchain/pinecone": "^0.0.1",
"@pinecone-database/pinecone": "^1.1.3",
"firebase-admin": "^11.11.1",
"firebase-functions": "^4.6.0",
"googleapis": "^126.0.1",
"langchain": "^0.1.9",
"moment-timezone": "^0.5.45",
"openai": "^4.26.1",
"wav-file-info": "^0.0.10"
},
"devDependencies": {
"firebase-functions-test": "^3.1.0"
},
"private": true
}

View File

@@ -0,0 +1,100 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const helpers = require("./helpers");
const textualy_graph = require("./textualy_graph");
const cors = require('cors')({origin: true});
/*
Example request body:
{
"companyId": "kVE8ut9G45Dp2J70p0FZ",
"conversationId": "Mon Feb 26 2024 11:57:19 AM"
}
*/
exports.handler = function(req, res) {
cors(req, res, async () => {
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.set('Access-Control-Max-Age', '3600');
return res.status(204).send('');
}
const reqBody = req.body;
// Stops the request if the required fields are not present
// List of required properties
const requiredProperties = ["companyId", "conversationId"];
// Check for missing properties
for (const property of requiredProperties) {
if (!reqBody.hasOwnProperty(property)) {
const bodyJson = {"success": false, "reason": `Request body missing required parameter: ${String(property)}`};
return res.status(403).send(bodyJson);
}
}
try {
const token = req.headers.authorization?.split('Bearer ')[1];
if (!token) {
return res.status(403).send('No token supplied on the request.');
}
// Gets the firestore instance
const firestore = admin.firestore();
// Get the user and their associated company ID from the token
const userId = await helpers.get_user_id_from_token(token);
const userDoc = await firestore.doc(`users/${userId}`).get();
if (!userDoc.exists) {
return res.status(401).send('User data does not exist.');
}
const userData = userDoc.data();
const userCompanyId = userData.ghlCompanyId;
// If the user's company ID doesn't match the requested ID, deny the whole request.
if (userCompanyId !== reqBody["companyId"]) {
return res.status(403).send('User company ID does not match request company ID.');
}
const companyPath = `TextualyCompanies/${userCompanyId}`;
const conversationPath = `${companyPath}/EmulatorConversations/${reqBody["conversationId"]}`;
const conversationDoc = await firestore.doc(conversationPath).get();
if (!conversationDoc.exists) {
return res.status(401).send('Conversation does not exist.');
}
const conversationData = conversationDoc.data();
const locationId = conversationData.location;
const agentId = conversationData.agent;
// Gets the AI response to the latest text in the conversation
const timezone = "America/Chicago";
let response = await textualy_graph.get_langchain_ai_response(firestore, companyPath, agentId, locationId, "EMULATOR_CONTACT", conversationPath, timezone, simulate=true);
const answer = response.answer;
if (!response.success) {
const bodyJson = {"success": false, "reason": response.reason};
await helpers.create_log_event("Emulator", locationId, "function/emulator_conversation", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
const bodyJson = {"success": true, answer};
await helpers.create_log_event("Emulator", locationId, "function/emulator_conversation", "APIResponse", bodyJson);
return res.status(200).send(bodyJson);
}
catch (error) {
const bodyJson = {"error": true, "success": false, "reason": `Internal error - ${error.message}: ${error.stack}`};
await helpers.create_log_event("Emulator", "NA", "function/emulator_conversation", "APIResponse", bodyJson);
return res.status(500).send(bodyJson);
}
});
};

View File

@@ -0,0 +1,308 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const helpers = require("./helpers");
const textualy_graph = require("./textualy_graph");
const cors = require('cors')({origin: true});
/*
Example request body:
{
"type": "OutboundMessage",
"locationId": "kVE8ut9G45Dp2J70p0FZ",
"attachments": [],
"body": "Hey Sheliah! This is Mark from Bi-County Chiropractic and Rehab! I saw you filled out the facebook form for our program just now\nFeel free to reply STOP to unsubscribe!",
"contactId": "py3Xl8kbtLsnoFkaU2El",
"contentType": "text/plain",
"conversationId": "Bo2X3qpiJlrQty5qKXKb",
"dateAdded": "2023-10-14T17:57:23.000Z",
"direction": "outbound",
"messageType": "SMS"
}
*/
exports.handler = function(req, res) {
cors(req, res, async () => {
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.set('Access-Control-Max-Age', '3600');
return res.status(204).send('');
}
const reqBody = req.body;
// Stops the request if the required fields are not present
// List of required properties
const requiredProperties = [
"type",
"locationId",
"body",
"contentType",
"conversationId",
"dateAdded",
"direction",
"messageType",
"contactId"
];
// Check for missing properties
for (const property of requiredProperties) {
if (!reqBody.hasOwnProperty(property)) {
console.error(`Request body missing required parameter: ${String(property)}`);
return res.status(403).send("Request body missing required parameter");
}
}
// Fetches all metadata for the conversation - lead data, location ID, company ID, and access token
const { locationId, contactId, direction, body } = reqBody;
try {
// Gets the firestore instance
const firestore = admin.firestore();
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIRequest", reqBody);
const companyId = await helpers.get_location_company_id(locationId, firestore);
const access_token = await helpers.get_access_token(locationId, firestore);
const leadData = await helpers.get_ghl_contact_data(access_token, contactId);
// All of Textualy going forward
// First determine the path to the conversation in the DB based on the company, location, and contact
const companyPath = `TextualyCompanies/${companyId}`;
const conversationPath = `${companyPath}/Conversations/${contactId}`;
// Second, create the conversation document if it doesn't exist already
let currDate = new Date();
let currDateStr = currDate.toISOString();
// Next, determine the agent to use based on tags, location, etc.
const leadTags = leadData["tags"];
if (!leadTags) {
const bodyJson = {"success": false, "reason": 'No tags for this lead.'};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(401).send(bodyJson);
}
// Get all the agents for the location as well as a compiled list of tags across all of those locations
const { agentsData, agentTags } = await helpers.get_agents_and_tags_for_location(firestore, companyId, locationId);
if (!agentsData.length) {
const bodyJson = {"success": false, "reason": 'No enabled agents found for this lead based on the location.'};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(401).send(bodyJson);
}
// Assign to the agent ID to ID of the first agent that has a tag match with a tag of the GHL contact
let agentId = "";
for (let tag of leadTags) {
if (agentTags.includes(tag)) {
// Assign the first agent that has this tag
agentId = agentsData.filter((agent) => agent.tags.includes(tag))[0]?.id || "";
break;
}
}
if (!agentId) {
const bodyJson = {"success": false, "reason": 'No agent found for this lead based on the location and tags.'};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(401).send(bodyJson);
}
let conversationDoc = await firestore.doc(conversationPath).get();
if (!conversationDoc.exists) {
try {
const conversationData = {
dateStarted: currDateStr,
dateUpdatedISO: (new Date()).toISOString(),
contactEmail: leadData["email"] || "",
contactFullName: `${leadData["firstName"] || ""} ${leadData["lastName"] || ""}`,
contactPhoneNumber: leadData["phone"] || "",
lastAgentId: "",
locationId: locationId,
agents: []
};
await helpers.set_db_record(firestore, conversationPath, conversationData);
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "DBWrite", {dbPath: conversationPath, ...conversationData});
}
catch (error) {
const bodyJson = {"error": true, "success": false, "reason": `Failed to create conversation in DB: ${error.message}: ${error.stack}`};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(500).send(bodyJson);
}
}
// Save database entry for the new text messasge from the lead if direction is inbound or from the AI if the direction is outbound
let messageData = {};
if (direction == "inbound") {
messageData = {
body: body,
dateAdded: reqBody.dateAdded,
direction: "inbound",
userId: contactId,
userName: `${leadData["firstName"]} ${leadData["lastName"]}`
};
}
else {
messageData = {
body: body,
dateAdded: reqBody.dateAdded,
direction: "outbound",
userId: "TextualyAI",
userName: "Textualy AI",
agentId
};
}
const conversationRef = firestore.doc(conversationPath);
let textMessages = await helpers.get_latest_texts(conversationRef, 1);
const messageDataPath = `${conversationPath}/Messages/${currDateStr}`;
const messageRef = firestore.doc(messageDataPath);
const messageDoc = await messageRef.get();
try {
if (messageDoc.exists) {
const bodyJson = {"success": false, "reason": "Request already processed"};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(422).send(bodyJson);
}
if (!textMessages[0] || textMessages[0]?.body !== body || direction === "inbound" || textMessages[0].direction !== direction) {
await helpers.set_db_record(firestore, messageDataPath, messageData);
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "DBWrite", {dbPath: messageDataPath, ...messageData});
}
}
catch (error) {
const bodyJson = {"error": true, "success": false, "reason": `Failed to save AI message to DB: ${error.message}: ${error.stack}`};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(500).send(bodyJson);
}
// Don't continue if it's an outbound message
if (direction !== "inbound") {
const bodyJson = {"success": true};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(200).send(bodyJson);
}
// Don't continue with the conversation if the lead replied stop
if (body.toLowerCase() == "stop") {
const bodyJson = {"success": false, "reason": "Lead replied STOP"};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
// Get the timestamp of the text that triggered this response from Textualy
// If any text from them or us is sent after this, there will be another execution
// of this function so we want this one to end
textMessages = await helpers.get_latest_texts(conversationRef, 1);
const originalTimestamp = textMessages[0].dateAdded;
// Wait a random time in case the lead double texts
// and to make the response more human-like
await helpers.wait_random_time(50, 80);
// Only continue if there hasn't been a newer text message
// Also GHL can sometimes send messages out of order, so if the last message
// is actually after the current one being processed, continue anyway
textMessages = await helpers.get_latest_texts(conversationRef, 1);
if (textMessages.length > 0 && textMessages[0].dateAdded !== originalTimestamp) {
const bodyJson = {"success": false, "reason": "New text message sent since the previous one was processed here."};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(409).send(bodyJson);
}
// After determining the agent to use, get the AI response to the latest text in the conversation
const timezone = leadData["timezone"];
let response = await textualy_graph.get_langchain_ai_response(firestore, companyPath, agentId, locationId, "EMULATOR_CONTACT", conversationPath, timezone, simulate=true);
if (!response.success) {
const bodyJson = {"error": true, "success": false, "reason": response.reason};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
// Only continue if there hasn't been a newer text message
textMessages = await helpers.get_latest_texts(conversationRef, 1);
if (textMessages.length > 0 && textMessages[0].dateAdded !== originalTimestamp) {
const bodyJson = {"success": false, "reason": "New text message sent since the previous one was processed here."};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(409).send(bodyJson);
}
// Only send the SMS message if an agent still applies to this lead
// Doesn't have to be the same agent but at least some agent still needs to apply
const leadDataNew = await helpers.get_ghl_contact_data(access_token, contactId);
const leadTagsNew = leadDataNew["tags"];
let newAgentId = "";
for (let tag of leadTagsNew) {
if (agentTags.includes(tag)) {
// Assign the first agent that has this tag
newAgentId = agentsData.filter((agent) => agent.tags.includes(tag))[0]?.id || "";
break;
}
}
if (!newAgentId) {
// Delete the message since it isn't getting sent
const resMessageDataPath = `${conversationPath}/Messages/${textMessages[0].dateAdded}`;
const resMessageRef = firestore.doc(resMessageDataPath);
await resMessageRef.delete();
const bodyJson = {"success": false, "reason": 'Agent no longer applies to this lead - not sending message.'};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(401).send(bodyJson);
}
// Update the last agent used for the lead and the list of agents used for the lead
conversationDoc = await firestore.doc(conversationPath).get();
const conversationData = conversationDoc.data();
try {
if (!(conversationData.agents || []).includes(agentId) || conversationData.lastAgentId !== agentId) {
const conversationDBData = {
lastAgentId: agentId,
agents: [...new Set([...(conversationData.agents || []), agentId])],
dateUpdatedISO: (new Date()).toISOString(),
replied: true
};
await helpers.set_db_record(firestore, conversationPath, conversationDBData);
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "DBWrite", {dbPath: conversationPath, ...conversationDBData});
}
else {
const conversationDBData = {
dateUpdatedISO: (new Date()).toISOString(),
replied: true
};
await helpers.set_db_record(firestore, conversationPath, conversationDBData);
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "DBWrite", {dbPath: conversationPath, ...conversationDBData});
}
} catch (error) {
const bodyJson = {"error": true, "success": false, "reason": `Failed to update the conversation in DB: ${error.message}: ${error.stack}`};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(500).send(bodyJson);
}
// Send the AI reply to the lead through the GHL API
await helpers.send_sms(access_token, contactId, answer);
const bodyJson = {"success": true};
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(200).send(bodyJson);
}
catch (error) {
let bodyJson = {"error": true, "success": false, "reason": `Internal error - ${error.message}: ${error.stack}`};
if (error.message.includes("Location data or company ID for location not found in Firestore")) {
bodyJson.error = false;
}
await helpers.create_log_event(contactId, locationId, "function/ghl_conversation", "APIResponse", bodyJson);
return res.status(500).send(bodyJson);
}
});
};

View File

@@ -0,0 +1,549 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const OpenAI = require("openai");
const Anthropic = require("@anthropic-ai/sdk");
const moment = require("moment-timezone");
const { GoogleGenerativeAI } = require("@google/generative-ai");
const { Logging } = require('@google-cloud/logging');
const { RetrievalQAChain, loadQAStuffChain } = require("langchain/chains");
const { PromptTemplate } = require("@langchain/core/prompts");
const { ChatOpenAI, OpenAIEmbeddings } = require("@langchain/openai");
const { PineconeStore } = require("@langchain/pinecone");
const { Pinecone } = require("@pinecone-database/pinecone");
const logging = new Logging();
// Creates a Google Cloud log event for an API request, DB write, or API response
exports.create_log_event = async function(contactId, locationId, path, type, data) {
const logData = {
locationId: locationId,
contactId: contactId,
operation: type,
endpoint: path,
details: data
};
const log = logging.log(locationId);
const entry = log.entry({}, logData);
await log.write(entry);
}
// Gets the current date in human readable format
exports.get_human_readable_date = function(date_to_convert=undefined) {
// 1. Parse the timestamp string to a Date object
let date = new Date();
if (date_to_convert) {
date = date_to_convert;
}
// 2. Extract the desired components
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // months are 0-based in JavaScript
const day = String(date.getDate()).padStart(2, '0');
let hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
// Convert 24-hour time to 12-hour time with AM/PM
let ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
hours = String(hours).padStart(2, '0');
// 3. Format into a human-readable string with AM/PM
const humanReadableDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${ampm}`;
return humanReadableDate;
}
// Gets the current date based on a timezone
exports.get_timezone_date = function(timezone, dateToParse=undefined) {
let currentDate = new Date();
if (dateToParse) {
currentDate = new Date(dateToParse);
}
let currentDateMoment = moment(currentDate.toISOString());
if (timezone) {
currentDateMoment = currentDateMoment.tz(timezone);
}
else {
currentDateMoment = currentDateMoment.tz("America/Chicago");
}
return (new Date(currentDateMoment.toLocaleString().split(" GMT")[0]));
}
exports.wait_random_time = async function(minSeconds, maxSeconds) {
const randomTimeInSeconds = Math.random() * (maxSeconds - minSeconds) + minSeconds;
const randomTimeInMilliseconds = randomTimeInSeconds * 1000;
return new Promise(resolve => setTimeout(resolve, randomTimeInMilliseconds));
}
// Gets a Firesbase user ID from the user token
exports.get_user_id_from_token = async function(token) {
try {
const decodedToken = await admin.auth().verifyIdToken(token);
return decodedToken.uid;
} catch (error) {
return undefined;
}
}
// Gets a record in the database
exports.get_db_record = async function(db, path) {
const dataRef = db.doc(path);
const dataDoc = await dataRef.get();
if (!dataDoc.exists) {
return undefined;
}
else {
return dataDoc.data();
}
}
// Sets a record in the database (creates if not present, otherwise update without clearing non updating fields)
exports.set_db_record = async function(db, path, data) {
const dataRef = db.doc(path);
await dataRef.set(data, { merge: true });
}
exports.get_location_company_id = async function(location_id, db) {
// Retrieve location data from Firestore
const locationRef = db.collection('LocationTokens').doc(location_id);
const locationData = (await locationRef.get()).data();
if (!locationData || !locationData.company_id) {
throw new Error("Location data or company ID for location not found in Firestore");
}
return locationData.company_id;
}
exports.get_access_token = async function(location_id, db, userType="Location") {
// Retrieve location data from Firestore
const locationRef = db.collection('LocationTokens').doc(location_id);
const locationData = (await locationRef.get()).data();
if (!locationData) {
throw new Error("Location data not found in Firestore");
}
// Check if current access token is still valid
const currentTime = new Date();
const tokenExpiration = new Date(locationData.access_token_expiration);
if (currentTime.getTime() + 8 * 60 * 60 * 1000 < tokenExpiration.getTime()) {
return locationData.access_token;
}
// Access token is expired, refresh it
const url = "https://services.leadconnectorhq.com/oauth/token";
const payload = new URLSearchParams();
payload.append('client_id', functions.config().ghl.client_id);
payload.append('client_secret', functions.config().ghl.client_secret);
payload.append('grant_type', 'refresh_token');
payload.append('refresh_token', locationData.refresh_token);
payload.append('user_type', userType);
const headers = {
"Content-Type": "application/x-www-form-urlencoded"
};
const response = await fetch(url, {
method: 'POST',
headers,
body: payload
});
if (!response.ok) {
throw new Error(`Failed to refresh access token: ${await response.text()}`);
}
const newTokenData = await response.json();
const newExpiration = new Date();
newExpiration.setSeconds(newExpiration.getSeconds() + newTokenData.expires_in);
// Update Firestore with the new token data
await locationRef.update({
access_token: newTokenData.access_token,
refresh_token: newTokenData.refresh_token,
access_token_expiration: newExpiration.toISOString()
});
return newTokenData.access_token;
}
exports.check_basic_authorization = function(req) {
if (Object.hasOwnProperty.call(req.headers, "authorization")) {
if (!(req.headers.authorization == functions.config().intro_call.authorization_pass)) {
return "Authorization header incorrect.";
}
else {
return undefined;
}
}
else {
return "No authorization header.";
}
}
exports.get_agents_and_tags_for_location = async function(db, companyId, locationId) {
const agentsSnapshot = await db.collection(`TextualyCompanies/${companyId}/Agents`)
.where('locations', 'array-contains', locationId)
.get();
const agentsSnapshotEmptyLocation = await db.collection(`TextualyCompanies/${companyId}/Agents`)
.where('locations', '==', [])
.get();
let agentsData = [];
let agentTags = [];
// Function to process each location documents snapshot
const processSnapshot = (snapshot) => {
snapshot.forEach(doc => {
const docData = doc.data();
if (docData.enabled) {
agentsData.push({ id: doc.id, ...docData });
agentTags = [...new Set([...agentTags, ...(docData.tags || [])])];
}
});
};
// Process both snapshots
processSnapshot(agentsSnapshot);
processSnapshot(agentsSnapshotEmptyLocation);
// Return combined data
return { agentsData: agentsData, agentTags: agentTags };
}
exports.get_latest_texts = async function(leadRef, numTexts=10) {
// Fetch the latest texts_to_fetch messages to determine who sent the last message and when
const texts_to_fetch = Number.parseInt(numTexts);
const messagesSnapshot = await leadRef.collection('Messages')
.orderBy('dateAdded', 'desc')
.limit(texts_to_fetch)
.get();
let messagesData = [];
messagesSnapshot.forEach(doc => {
messagesData.push(doc.data());
});
// Reverse the list at the end
messagesData.reverse();
return messagesData;
}
exports.create_conversation_string = function(textMessages, timezone) {
let conversation = "";
textMessages.forEach(message => {
// Format the timestamp for readability
const currentDate = exports.get_timezone_date(timezone, message.dateAdded);
const timestamp = exports.get_human_readable_date(currentDate);
// Indicate the direction of the message
const direction = message.direction === 'outbound' ? "From us:" : "From lead:";
// Append each message to the conversation string
conversation += `${timestamp} ${direction}\n${message.body}\n\n`;
});
return conversation;
}
exports.ask_vector_db_question = async function(indexName, openAIApiKey, question, namespace=undefined, metadataFilter=undefined) {
const pinecone = new Pinecone({
apiKey: functions.config().pinecone.api_key,
environment: functions.config().pinecone.environment
});
const pineconeIndex = pinecone.index(indexName);
const storeOptions = { pineconeIndex };
if (metadataFilter) {
storeOptions.filter = metadataFilter;
}
if (namespace) {
storeOptions.namespace = namespace;
}
const vectorStore = await PineconeStore.fromExistingIndex(
new OpenAIEmbeddings({ openAIApiKey: openAIApiKey }), storeOptions
);
const retriever = vectorStore.asRetriever({ k: 3 });
const model = new ChatOpenAI({ openAIApiKey: openAIApiKey });
const template = `Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
Use three sentences maximum and keep the answer as concise as possible.
{context}
Question (including previous messages for more context): {question}
Helpful Answer:`;
const QA_CHAIN_PROMPT = new PromptTemplate({
inputVariables: ["context", "question"],
template,
});
const chain = new RetrievalQAChain({
combineDocumentsChain: loadQAStuffChain(model, { prompt: QA_CHAIN_PROMPT }),
retriever,
returnSourceDocuments: true,
inputKey: "question",
});
const response = await chain.invoke({question});
return response;
}
exports.get_location_calendar = async function(db, calendarPath) {
const calendarDoc = await db.doc(calendarPath).get();
if (!calendarDoc.exists) {
return undefined;
}
return calendarDoc.data();
}
exports.add_or_remove_contact_tag = async function(access_token, contactId, tag, operation) {
const url = `https://services.leadconnectorhq.com/contacts/${contactId}/tags`;
const options = {
method: operation,
headers: {
Authorization: `Bearer ${access_token}`,
Version: '2021-07-28',
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({tags: [tag]})
};
try {
const response = await fetch(url, options);
const data = await response.json();
return data;
} catch (error) {
console.error(error);
return error;
}
}
exports.invoke_webhook_with_params = function(url, params) {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
});
}
exports.get_calendar_availability = async function(access_token, calendarId) {
const url = `https://services.leadconnectorhq.com/calendars/${calendarId}/free-slots?startDate=0&endDate=2000000000000000`;
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${access_token}`,
Version: '2021-04-15',
Accept: 'application/json'
}
};
try {
const response = await fetch(url, options);
const data = await response.json();
return data;
} catch (error) {
console.error(error);
return undefined;
}
}
exports.get_contact_appointments = async function(access_token, contactId) {
const url = `https://services.leadconnectorhq.com/contacts/${contactId}/appointments`;
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${access_token}`,
Version: '2021-07-28',
Accept: 'application/json'
}
};
try {
const response = await fetch(url, options);
const data = await response.json();
return data;
} catch (error) {
console.error(error);
return {statusCode: 400, message: "Couldn't fetch existing appointments."};
}
}
exports.cancel_appointment = async function(access_token, calendarId, contactId, locationId, eventId) {
const url = `https://services.leadconnectorhq.com/calendars/events/appointments/${eventId}`;
const options = {
method: 'PUT',
headers: {
Authorization: `Bearer ${access_token}`,
Version: '2021-04-15',
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({calendarId, locationId, contactId, appointmentStatus: "cancelled"})
};
try {
const response = await fetch(url, options);
const data = await response.json();
return data;
} catch (error) {
console.error(error);
return {statusCode: 400, message: "Couldn't cancel the existing appointment."};
}
}
// Cancels an existing appointment to reschedule but still cause the triggers for booking in GHL
exports.cancel_existing_appointment = async function(db, access_token, calendarId, contactId, locationId, companyPath) {
await exports.add_or_remove_contact_tag(access_token, contactId, "textualy-cancelled", "POST");
// Get existing appointments for the contact
const contactAppointments = await exports.get_contact_appointments(access_token, contactId);
if (contactAppointments?.statusCode === 400) {
return contactAppointments;
}
// Check if they are scheduled for the same calendar with any appointment
const sameCalendarBookings = contactAppointments?.events?.filter((event) => event.calendarId === calendarId) || [];
// For every event from the same calendar, cancel it because we're about to book another appointment to reschedule
for (let calendarBooking of sameCalendarBookings) {
const cancelAppointmentResult = await exports.cancel_appointment(access_token, calendarId, contactId, locationId, calendarBooking.id);
if (cancelAppointmentResult?.statusCode === 400) {
return cancelAppointmentResult;
}
}
const didCancel = sameCalendarBookings.length > 0;
// If an appointment was cancelled, mark the lead as not booked
if (didCancel) {
const conversationPath = `${companyPath}/Conversations/${contactId}`;
await exports.set_db_record(db, conversationPath, {currBooked: false});
}
return {rescheduled: didCancel};
}
exports.book_appointment = async function(access_token, db, calendarId, locationId, contactId, companyPath, startTime) {
const cancelAppointmentResult = await exports.cancel_existing_appointment(db, access_token, calendarId, contactId, locationId, companyPath);
console.log(cancelAppointmentResult);
if (cancelAppointmentResult?.statusCode === 400) {
return cancelAppointmentResult;
}
await exports.wait_random_time(12, 15);
const url = 'https://services.leadconnectorhq.com/calendars/events/appointments';
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
Version: '2021-04-15',
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({calendarId, locationId, contactId, startTime, toNotify: true})
};
try {
const response = await fetch(url, options);
const data = await response.json();
// Add the tag that signals that Textualy booked the lead
// await exports.add_or_remove_contact_tag(access_token, contactId, "textualy-booked", "POST");
// Update the conversation in the DB to say that Textualy booked the lead
const conversationPath = `${companyPath}/Conversations/${contactId}`;
await exports.set_db_record(db, conversationPath, {booked: true, currBooked: true, rescheduled: cancelAppointmentResult.rescheduled || false});
return data;
} catch (error) {
console.error(error);
return {statusCode: 400, message: "Couldn't book the appointment at the requested time."};
}
}
exports.send_sms = async function(apiKey, contactId, message) {
// Sends an SMS through the GHL API.
const url = "https://services.leadconnectorhq.com/conversations/messages";
const payload = {
type: "SMS",
contactId: contactId,
message: message
};
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Version": "2021-04-15",
"Content-Type": "application/json",
"Accept": "application/json"
};
try {
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to send SMS:", error);
throw error;
}
}
exports.get_ghl_contact_data = async function(access_token, contactId, retries=0) {
// Queries GHL to get the contact information for the contact in the current conversation
const url = `https://services.leadconnectorhq.com/contacts/${contactId}`;
const headers = {
'Accept': 'application/json',
'Authorization': `Bearer ${access_token}`,
'Version': '2021-07-28'
};
const response = await fetch(url, { method: 'GET', headers });
const leadData = (await response.json())["contact"];
if (!leadData && retries < 3) {
await new Promise((resolve) => setTimeout(resolve, 10000)); // 10 seconds delay
return exports.get_ghl_contact_data(access_token, contactId, retries + 1);
}
return leadData;
}

View File

@@ -0,0 +1,37 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const locationKnowledgeBase = require("./location_knowledge_base");
const GHLConversationLangChain = require("./ghl_conversation_langchain");
const EmulatorConversationLangChain = require("./emulator_conversation_langchain");
if (functions.config().firestore.GOOGLE_PRIVATE_KEY) {
const firebaseJson = {
projectId: functions.config().firestore.GOOGLE_PROJECT_ID,
clientEmail: functions.config().firestore.GOOGLE_CLIENT_EMAIL,
privateKey: functions.config().firestore.GOOGLE_PRIVATE_KEY?.replaceAll(/\\n/g, '\n')
}
admin.initializeApp({ credential: admin.credential.cert(firebaseJson) });
}
else {
admin.initializeApp();
}
exports.updateLocationKnowledgeBase = functions.https.onRequest(async (req, res) => {
locationKnowledgeBase.handler(req, res);
});
exports.GHLConversationLangChain = functions.runWith({
timeoutSeconds: 200,
memory: '256MB'
}).https.onRequest(async (req, res) => {
GHLConversationLangChain.handler(req, res);
});
exports.EmulatorConversationLangChain = functions.runWith({
timeoutSeconds: 200,
memory: '256MB'
}).https.onRequest(async (req, res) => {
EmulatorConversationLangChain.handler(req, res);
});

View File

@@ -0,0 +1,161 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const { Document } = require("@langchain/core/documents");
const { OpenAIEmbeddings } = require("@langchain/openai");
const { PineconeStore } = require("@langchain/pinecone");
const { Pinecone } = require("@pinecone-database/pinecone");
const helpers = require("./helpers");
const cors = require('cors')({origin: true});
exports.handler = function(req, res) {
cors(req, res, async () => {
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.set('Access-Control-Max-Age', '3600');
return res.status(204).send('');
}
// Check for POST request
if (req.method !== "POST") {
return res.status(400).send('Please send a POST request');
}
const reqBody = req.body;
// Stops the request if the required fields are not present
// List of required properties
const requiredProperties = ["locationId", "oldFAQ", "newFAQ"];
// Check for missing properties
for (const property of requiredProperties) {
if (!reqBody.hasOwnProperty(property)) {
const bodyJson = {"success": false, "reason": `Request body missing required parameter: ${String(property)}`};
return res.status(403).send(bodyJson);
}
}
// Deconstruct the request body to get everything needed for the knowledgebase update
const { locationId, oldFAQ, newFAQ } = reqBody;
try {
const token = req.headers.authorization?.split('Bearer ')[1];
if (!token) {
return res.status(403).send('No token supplied on the request.');
}
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIRequest", reqBody);
// Can't have more than 100 FAQs
if (newFAQ.length > 100) {
const bodyJson = {"success": false, "reason": "Can't have more than 100 FAQs for a location."};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
// Gets the firestore instance
const firestore = admin.firestore();
// Get the user and their associated company ID from the token
const userId = await helpers.get_user_id_from_token(token);
const userDoc = await firestore.doc(`users/${userId}`).get();
if (!userDoc.exists) {
const bodyJson = {"success": false, "reason": "User data does not exist."};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(401).send(bodyJson);
}
const userData = userDoc.data();
const userCompanyId = userData.ghlCompanyId;
// Get the company ID based on the location ID
const companyId = await helpers.get_location_company_id(locationId, firestore);
// If the user's company ID doesn't match the requested ID, deny the whole request.
if (userCompanyId !== companyId) {
const bodyJson = {"success": false, "reason": "User company ID does not match the company ID for the requested location."};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
// Get the open AI api key from the first agent for the company
const companyPath = `TextualyCompanies/${userCompanyId}`;
const agentsPath = `${companyPath}/Agents`;
const agentDocs = await firestore.collection(agentsPath).get();
if (agentDocs.empty) {
const bodyJson = {"success": false, "reason": "You must create an agent before updating location configuration."};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
const firstAgentAPIKey = agentDocs.docs[0].data().openAIAPIKey;
if (!firstAgentAPIKey) {
const bodyJson = {"success": false, "reason": "Your agents need Open AI API keys before you can configure the knowledgebase for locations."};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(403).send(bodyJson);
}
// Initialize Pinecone
const pinecone = new Pinecone({
apiKey: functions.config().pinecone.api_key,
environment: functions.config().pinecone.environment
});
const pineconeIndex = pinecone.index(functions.config().pinecone.index);
// Get the vector store from Pinecone with the namespace for the location
const vectorStore = await PineconeStore.fromExistingIndex(
new OpenAIEmbeddings({ openAIApiKey: firstAgentAPIKey }), { pineconeIndex, namespace: locationId });
const newFAQQuestions = newFAQ.map((faq) => faq.question);
const oldFAQQuestions = oldFAQ.map((faq) => faq.question);
// Delete vectors for FAQs that were deleted
const deletedFAQQuestions = oldFAQQuestions.filter((question) => !newFAQQuestions.includes(question));
if (deletedFAQQuestions.length) {
await vectorStore.delete({
ids: deletedFAQQuestions,
namespace: locationId
});
}
// Upsert the new FAQs
const newFAQFiltered = newFAQ
.filter(faq =>
!oldFAQQuestions.includes(faq.question) ||
oldFAQ.find(f => f.question === faq.question).answer !== faq.answer
)
const newFAQDocs = newFAQFiltered
.map(faq => new Document({
metadata: {
question: faq.question,
answer: faq.answer
},
pageContent: `Question: ${faq.question}\nAnswer: ${faq.answer}`
}));
const newFAQIds = newFAQFiltered.map((faq) => faq.question);
let newFAQVectorIds = [];
if (newFAQDocs.length) {
newFAQVectorIds = await vectorStore.addDocuments(newFAQDocs, {ids: newFAQIds, namespace: locationId});
}
const bodyJson = {"success": true, "newFAQVectorIds": newFAQVectorIds};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(200).send(bodyJson);
}
catch (error) {
const bodyJson = {"success": false, "reason": `Internal error - ${error.message}: ${error.stack}`};
await helpers.create_log_event("NA", locationId, "function/location_knowledge_base", "APIResponse", bodyJson);
return res.status(500).send(bodyJson);
}
});
};

View File

@@ -0,0 +1,353 @@
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const { SystemMessage, HumanMessage, AIMessage, ToolMessage } = require("@langchain/core/messages");
const { ToolNode } = require("@langchain/langgraph/prebuilt");
const { ChatOpenAI } = require("@langchain/openai");
const { StateGraphArgs } = require("@langchain/langgraph");
const { END } = require("@langchain/langgraph");
const { START, StateGraph } = require("@langchain/langgraph");
const helpers = require("./helpers");
const textualy_tools = require("./textualy_tools");
// LangGraph state - all context needed for the conversation
const agentState = {
messages: {
value: (x, y) => x.concat(y),
default: () => [],
},
textualy: {
// Overwrite if a new Textualy object is provided
value: (x, y) => (y ? y : x),
default: () => undefined
},
locationId: {
value: (x, y) => (y ? y : x),
default: () => undefined
},
contactId: {
value: (x, y) => (y ? y : x),
default: () => undefined
},
companyPath: {
value: (x, y) => (y ? y : x),
default: () => undefined
},
conversationPath: {
value: (x, y) => (y ? y : x),
default: () => undefined
},
simulate: {
value: (x, y) => (y ? y : x),
default: () => undefined
}
};
// Define the function that determines whether to continue or not
const shouldContinue = (state) => {
const { messages } = state;
const lastMessage = messages[messages.length - 1];
// If there is no function call, then we finish
if (!lastMessage?.tool_calls?.length) {
return END;
}
else {
return "tools";
}
};
// Define the function that handles all tool calls
const toolNode = async (state, config) => {
const tools = textualy_tools.tools;
const { messages, locationId, contactId, companyPath, conversationPath, simulate } = state;
const lastMessage = messages[messages.length - 1];
// Loop over every tool call and make the call + create the ToolMessage for the call results
const outputs = await Promise.all(
lastMessage.tool_calls?.map(async (call) => {
const tool = tools.find((tool) => tool.name === call.name);
// Tool not found
if (tool === undefined) {
throw new Error(`Tool "${call.name}" not found.`);
}
// Invoke the tool with the arguments the AI requested
// as well as the extra context needed like location ID, contact ID, etc.
const output = await tool.invoke(
{ ...call.args, locationId: locationId, contactId: contactId, companyPath: companyPath, simulate: simulate },
config
);
// Crafts the new database entry for the new tool call messasge
const currDate = new Date();
const currDateStr = currDate.toISOString();
const messageData = {
body: typeof output === "string" ? output : JSON.stringify(output),
dateAdded: currDateStr,
direction: "outbound",
userId: "TextualyAI",
userName: "Textualy AI",
tool_name: tool.name,
tool_call_id: call.id
}
// Add the tool message to the conversation in the DB
const firestore = admin.firestore();
const messageDataPath = `${conversationPath}/Messages/${currDateStr}`;
await helpers.set_db_record(firestore, messageDataPath, messageData);
const functionName = simulate ? "emulator_conversation" : "ghl_conversation";
await helpers.create_log_event("Emulator", locationId, `function/${functionName}`, "DBWrite", {dbPath: messageDataPath, ...messageData});
return new ToolMessage({
name: tool.name,
content: typeof output === "string" ? output : JSON.stringify(output),
tool_call_id: call.id
});
}) ?? []
);
return { messages: outputs }
}
// Define the function that calls the model
const callModel = async (state, config) => {
const { messages, textualy, conversationPath, locationId, simulate } = state;
// Invoke the LLM with the conversation history and LangGraph config
const response = await textualy.invoke(messages, config);
// Crafts the new database entry for the new text messasge from the AI
const currDate = new Date();
const currDateStr = currDate.toISOString();
let messageData = {
body: response.content,
dateAdded: currDateStr,
direction: "outbound",
userId: "TextualyAI",
userName: "Textualy AI",
showUser: response.content.length > 0
}
if (response?.tool_calls?.length) {
messageData.tool_calls = response.tool_calls;
}
// Stores the AI's response in the DB
const firestore = admin.firestore();
const messageDataPath = `${conversationPath}/Messages/${currDateStr}`;
await helpers.set_db_record(firestore, messageDataPath, messageData);
const functionName = simulate ? "emulator_conversation" : "ghl_conversation";
await helpers.create_log_event("Emulator", locationId, `function/${functionName}`, "DBWrite", {dbPath: messageDataPath, ...messageData});
// Return an object, because this will get added to the existing list
return { messages: [response] };
};
// Defines the graph for the whole lead nurturing process
const workflow = new StateGraph({ channels: agentState })
// Define the two nodes it will cycle between
.addNode("agent", callModel)
// Note the "action" and "final" nodes are identical
.addNode("tools", toolNode)
.addNode("final", toolNode)
// Set the entrypoint as as the node that invokes the LLM
.addEdge(START, "agent")
// We now add a conditional edge
.addConditionalEdges(
// First, define the start node which is the agent
"agent",
// Next, pass in the function that will determine which node is called next.
shouldContinue,
)
// Now add a normal edge from `tools` to `agent`.
.addEdge("tools", "agent")
.addEdge("final", END);
// Formats an array of messages into the format needed for the LLM
// (human messages, AI messages, tool messages, etc.)
exports.format_conversation_for_llm = function(prompt, textMessages) {
let conversation = [];
conversation.push(new SystemMessage(prompt));
textMessages.forEach(message => {
conversation.push(
message.tool_call_id ? new ToolMessage({
name: message.tool_name,
tool_call_id: message.tool_call_id,
content: message.body
})
:
(
message.direction === "inbound"
? new HumanMessage(message.body)
: new AIMessage(content=message.body, additional_kwargs=(message.tool_calls ? {
tool_calls: message.tool_calls.map((tool_call) => ({
...tool_call,
type: "function",
"function": { name: tool_call.name, arguments: JSON.stringify(tool_call.args) }
}))
} : {}))
)
);
});
return conversation;
}
// Function to invoke the LangGraph executable to get the next conversation message from the AI
// and invoke any necessary tools to book on the calendar, add a tag, get calendar availability, etc.
exports.get_llm_response = async function(agent, conversation, locationId, contactId, companyPath, conversationPath, simulate) {
const apiKey = agent.openAIAPIKey;
// Creates the LLM instance (just OpenAI right now but this could easily be extended to include other models)
const model = new ChatOpenAI({
model: agent.model,
apiKey
});
const textualy = model.bindTools(textualy_tools.tools);
// Compiles the LangGraph graph for execution
const app = workflow.compile();
// Defines all the inputs for the graph including extra context needed
// for the conversation like the location ID and contact ID
const inputs = { messages: conversation, textualy: textualy, locationId, contactId, companyPath, conversationPath, simulate };
// Streams the output from the LangGraph execution and returns the final response from the AI
// after any potential tool calls
let finalMessage = "";
for await (const output of await app.stream(inputs, { streamMode: "values" })) {
const lastMessage = output.messages[output.messages.length - 1];
finalMessage = lastMessage;
}
return finalMessage.content;
}
// Function to add all the dynamic actions to the prompt
// This is how you can have different calendars from different GHL locations,
// instructions on when specifically to book a lead on the calendar,
// different tags to add to leads in different situations, etc.
exports.build_actions = async function(db, companyPath, locationId, agent) {
let actions = "";
// Get the data for each action and append the instructions to the action string
// actionType can be one of: "Text Calendar Availability" | "Book Appointment" | "Cancel Appointment" | "Add Tag" | "Remove Tag" | "Invoke Webhook"
for (let actionId of agent.actions) {
const actionPath = `${companyPath}/Actions/${actionId}`;
const actionDoc = await db.doc(actionPath).get();
if (!actionDoc.exists) {
continue;
}
const { actionType, actionParameter, trigger } = actionDoc.data();
let actionParamText = `'${actionParameter}'`;
// Extra context needed if it is a calendar based action
// since the LLM needs the calendar ID and the calendar name
if (actionParameter === "Set the calendar for this action in each location individually") {
const calendarPath = `${companyPath}/Locations/${locationId}/ActionCalendars/${actionId}`;
const locationCalendar = await helpers.get_location_calendar(db, calendarPath);
actionParamText = `with calendar ID: '${locationCalendar.calendarId} and calendar name: ${locationCalendar.calendarName})`;
}
actions += `\n${trigger} - ${actionType} ${actionParamText}`;
}
return actions;
}
// Main function to get the next message in the GHL conversation from the LLM
// This has RAG for FAQs, dynamic prompts and actions, all the bells and whistles!
exports.get_langchain_ai_response = async function(db, companyPath, agentId, locationId, contactId, conversationPath, timezone, simulate=false) {
// Gets the access token for the location and refreshes it if necessary
const access_token = await helpers.get_access_token(locationId, db);
const agentPath = `${companyPath}/Agents/${agentId}`;
const agentDoc = await db.doc(agentPath).get();
if (!agentDoc.exists) {
return {success: false, reason: "Agent does not exist."};
}
const agent = agentDoc.data();
// Get the location context
const locationPath = `${companyPath}/Locations/${locationId}`;
const locationDoc = await db.doc(locationPath).get();
if (!locationDoc.exists) {
return {success: false, reason: "Location does not exist."};
}
const locationData = locationDoc.data();
const locationContext = locationData.context;
const promptDoc = await db.doc(`${companyPath}/Prompts/${agent.prompt}`).get();
const conversationRef = db.doc(conversationPath);
const textMessages = await helpers.get_latest_texts(conversationRef, functions.config().ghl.texts_to_fetch);
// There need to be messages and a valid prompt to continue
if (textMessages.length === 0 || !promptDoc || !promptDoc.data().prompt) {
return {success: false, reason: "Prompt or texts invalid for this request."};
}
const conversationStr = helpers.create_conversation_string(
textMessages.length > 2 ? textMessages.slice(-2) : [...textMessages],
timezone
);
// Checks the knowledgebase with RAG for anything pertaining to the text and adds it to the prompt if it finds something
let faqContext = "";
try {
const response = await helpers.ask_vector_db_question(
functions.config().pinecone.index,
agent.openAIAPIKey,
conversationStr,
locationId
);
if (response.sourceDocuments) {
faqContext = response.sourceDocuments.reduce((accumulator, currentDocument) => {
return accumulator + currentDocument.pageContent + '\n\n';
}, '');
}
} catch (error) {
console.log(error);
}
// Include any actions in the prompt if it's the initial prompt and the agent has actions tied to it
let actions = "";
if (agent.actions.length) {
actions = await exports.build_actions(db, companyPath, locationId, agent);
}
// Build up the prompt by replacing the placeholders with the timestamp and conversation
const currDateTime = helpers.get_human_readable_date(helpers.get_timezone_date(timezone));
let prompt = promptDoc.data().prompt;
const originalPrompt = prompt;
// Add on the context fetched from the regular DB and vector DB
prompt += faqContext ? `\n\nFAQ for more context: \n${faqContext}` : "\n\n";
prompt += locationContext ? `More context for the location: \n${locationContext}\n\n` : "";
// Only include actions instructions if this isn't the second prompt after performing an action
prompt += actions ? `List of specific triggers that require you to prepend your response with the ID of an action to take (only choose zero or one): \n${actions}\nIf the lead asks for availablility, take that action even if you think you know the availability already.` : "";
// Adds on the context given for every message, including date information for days such as today, tomorrow, the next day, etc.
prompt += `\n\nHere is some information for you on dates: ${helpers.generate_date_context(timezone)}`;
// Add the current timestamp to the prompt so the AI knows what time it is
prompt += `\n\nThe current time in the timezone of the lead is: ${currDateTime}. Your output will be sent directly to the lead as the next text message.`;
// Create the conversation - each message includes the timestamp, from us or them, and the message
const conversation = exports.format_conversation_for_llm(prompt, textMessages);
// Get the next text message from the AI with the above prompt
const answer = await exports.get_llm_response(agent, conversation, locationId, contactId, companyPath, conversationPath, simulate);
return {success: true, prompt: originalPrompt, answer, agent};
}

View File

@@ -0,0 +1,146 @@
const { DynamicStructuredTool } = require("@langchain/core/tools");
const admin = require("firebase-admin");
const { z } = require("zod");
const helpers = require("./helpers");
const AddTagSchema = z.object({
locationId: z.string().optional().describe("The location ID of the lead"),
contactId: z.string().optional().describe("The contact ID of the lead"),
simulate: z.boolean().optional().describe("Whether or not this is a simulated run"),
tag: z.string().describe("The tag to add to the lead")
});
const RemoveTagSchema = z.object({
locationId: z.string().optional().describe("The location ID of the lead"),
contactId: z.string().optional().describe("The contact ID of the lead"),
simulate: z.boolean().optional().describe("Whether or not this is a simulated run"),
tag: z.string().describe("The tag to remove from the lead")
});
const InvokeWebhookSchema = z.object({
locationId: z.string().optional().describe("The location ID of the lead"),
contactId: z.string().optional().describe("The contact ID of the lead"),
simulate: z.boolean().optional().describe("Whether or not this is a simulated run"),
url: z.string().describe("The webhook URL to invoke")
});
const CalendarAvailabilitySchema = z.object({
locationId: z.string().optional().describe("The location ID of the lead"),
calendarId: z.string().describe("The ID of the calendar to get the availability from")
});
const CancelAppointmentSchema = z.object({
locationId: z.string().optional().describe("The location ID of the lead"),
contactId: z.string().optional().describe("The contact ID of the lead"),
simulate: z.boolean().optional().describe("Whether or not this is a simulated run"),
companyPath: z.string().optional().describe("The path in the DB to the company data"),
calendarId: z.string().describe("The ID of the calendar to get the availability from"),
calendarName: z.string().describe("The name of the calendar")
});
const BookAppointmentSchema = z.object({
locationId: z.string().optional().describe("The location ID of the lead"),
contactId: z.string().optional().describe("The contact ID of the lead"),
simulate: z.boolean().optional().describe("Whether or not this is a simulated run"),
companyPath: z.string().optional().describe("The path in the DB to the company data"),
calendarId: z.string().describe("The ID of the calendar to get the availability from"),
calendarName: z.string().describe("The name of the calendar"),
bookingTime: z.string().describe("The time to book the appointment in the format 2024-02-25T11:00:00")
});
const addTagTool = new DynamicStructuredTool({
name: "add_tag",
description: "Call to add a tag to the lead",
schema: AddTagSchema,
func: async ({locationId, contactId, simulate, tag}) => {
if (!simulate) {
const db = admin.firestore();
const access_token = await helpers.get_access_token(locationId, db);
return (await helpers.add_or_remove_contact_tag(access_token, contactId, tag, "POST"));
}
else {
console.log(`Tag "${tag}" added to lead.`);
return `Tag "${tag}" added to lead.`;
}
}
});
const removeTagTool = new DynamicStructuredTool({
name: "remove_tag",
description: "Call to remove a tag from the lead",
schema: RemoveTagSchema,
func: async ({locationId, contactId, simulate, tag}) => {
if (!simulate) {
const db = admin.firestore();
const access_token = await helpers.get_access_token(locationId, db);
return (await helpers.add_or_remove_contact_tag(access_token, contactId, tag, "DELETE"));
}
else {
console.log(`Tag "${tag}" removed from lead.`);
return `Tag "${tag}" removed from lead.`;
}
}
});
const invokeWebhookTool = new DynamicStructuredTool({
name: "invoke_webhook",
description: "Call to invoke a webhook",
schema: InvokeWebhookSchema,
func: async ({locationId, contactId, simulate, url}) => {
if (!simulate) {
helpers.invoke_webhook_with_params(actionParameter, {locationId, contactId});
}
else {
console.log(`URL "${url}" would have been invoked.`);
}
return `URL "${url}" was invoked.`;
}
});
const getCalendarAvailabilityTool = new DynamicStructuredTool({
name: "get_calendar_availability",
description: "Call to get availability from a calendar",
schema: CalendarAvailabilitySchema,
func: async ({locationId, calendarId}) => {
const db = admin.firestore();
const access_token = await helpers.get_access_token(locationId, db);
return (await helpers.get_calendar_availability(access_token, calendarId));
}
});
const cancelAppointmentTool = new DynamicStructuredTool({
name: "cancel_appointment",
description: "Call to cancel an appointment",
schema: CancelAppointmentSchema,
func: async ({locationId, contactId, simulate, companyPath, calendarId, calendarName}) => {
const db = admin.firestore();
if (!simulate) {
const access_token = await helpers.get_access_token(locationId, db);
return (await helpers.cancel_existing_appointment(db, access_token, calendarId, contactId, locationId, companyPath));
}
else {
return `Appointment has have been cancelled on the calendar "${calendarName}"`;
}
}
});
const bookAppointmentTool = new DynamicStructuredTool({
name: "book_appointment",
description: "Call to book an appointment",
schema: BookAppointmentSchema,
func: async ({locationId, contactId, simulate, companyPath, calendarId, calendarName, bookingTime}) => {
const db = admin.firestore();
if (!simulate) {
const access_token = await helpers.get_access_token(locationId, db);
return (await helpers.book_appointment(access_token, db, calendarId, locationId, contactId, companyPath, bookingTime));
}
else {
return `Appointment has been booked for "${bookingTime}" on the calendar "${calendarName}"`;
}
}
});
exports.tools = [addTagTool, removeTagTool, invokeWebhookTool, getCalendarAvailabilityTool, cancelAppointmentTool, bookAppointmentTool];