mirror of
https://github.com/coleam00/ai-agents-masterclass.git
synced 2025-11-29 08:33:16 +00:00
GHL SaaS Backend
This commit is contained in:
5
gohighlevel-saas-backend/.firebaserc
Normal file
5
gohighlevel-saas-backend/.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "ghl-saas-backend"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
gohighlevel-saas-backend/.gitignore
vendored
Normal file
68
gohighlevel-saas-backend/.gitignore
vendored
Normal 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
|
||||||
22
gohighlevel-saas-backend/firebase.json
Normal file
22
gohighlevel-saas-backend/firebase.json
Normal 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/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
gohighlevel-saas-backend/firestore.indexes.json
Normal file
4
gohighlevel-saas-backend/firestore.indexes.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"indexes": [],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
9
gohighlevel-saas-backend/functions/.gitignore
vendored
Normal file
9
gohighlevel-saas-backend/functions/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Compiled JavaScript files
|
||||||
|
lib/**/*.js
|
||||||
|
lib/**/*.js.map
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Node.js dependency directory
|
||||||
|
node_modules/
|
||||||
15
gohighlevel-saas-backend/functions/jsconfig.json
Normal file
15
gohighlevel-saas-backend/functions/jsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"outDir": "lib",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "es2017"
|
||||||
|
},
|
||||||
|
"compileOnSave": true,
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
8682
gohighlevel-saas-backend/functions/package-lock.json
generated
Normal file
8682
gohighlevel-saas-backend/functions/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
gohighlevel-saas-backend/functions/package.json
Normal file
34
gohighlevel-saas-backend/functions/package.json
Normal 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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
549
gohighlevel-saas-backend/functions/src/helpers.js
Normal file
549
gohighlevel-saas-backend/functions/src/helpers.js
Normal 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;
|
||||||
|
}
|
||||||
37
gohighlevel-saas-backend/functions/src/index.js
Normal file
37
gohighlevel-saas-backend/functions/src/index.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
353
gohighlevel-saas-backend/functions/src/textualy_graph.js
Normal file
353
gohighlevel-saas-backend/functions/src/textualy_graph.js
Normal 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};
|
||||||
|
}
|
||||||
146
gohighlevel-saas-backend/functions/src/textualy_tools.js
Normal file
146
gohighlevel-saas-backend/functions/src/textualy_tools.js
Normal 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];
|
||||||
Reference in New Issue
Block a user