mirror of
https://github.com/coleam00/ai-agents-masterclass.git
synced 2025-11-29 00:23:14 +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