Last active
October 4, 2023 08:42
-
-
Save jakebloom/2d8468229eb40b99b72e039fd2150831 to your computer and use it in GitHub Desktop.
Firebase function for a whatsapp bot that recommends movies and tv shows
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
ASK QWOKKA: Your whatsapp buddy for recommending great movies and TV shows. | |
TRY IT NOW: https://ask.qwokka.io | |
*/ | |
import * as functions from "firebase-functions"; | |
import * as admin from "firebase-admin"; | |
import axios from "axios"; | |
import { | |
ChatCompletionRequestMessage, | |
ChatCompletionRequestMessageRoleEnum, | |
Configuration, | |
OpenAIApi, | |
} from "openai"; | |
type MessageType = "incoming" | "outgoing"; | |
interface Message { | |
type: MessageType; | |
content: string; | |
} | |
interface Button { | |
id: string; | |
title: string; | |
} | |
interface WhatsappButton { | |
type: "reply"; | |
reply: { | |
id: string; | |
title: string; | |
} | |
} | |
type WhatsappTextMessage = { | |
type: "text"; | |
text: { | |
body: string; | |
preview_url: boolean; | |
} | |
} | |
type WhatsappButtonMessage = { | |
type: "interactive"; | |
interactive: { | |
type: "button" | |
body: { | |
text: string; | |
}; | |
action: { | |
buttons: WhatsappButton[]; | |
}; | |
}; | |
} | |
type WhatsappMessage = WhatsappTextMessage | WhatsappButtonMessage; | |
const PHONE_NUMBER_ID = functions.params | |
.defineString("WHATSAPP_PHONE_NUMBER_ID").value(); | |
const WHATSAPP_ACCESS_TOKEN = functions.params | |
.defineString("WHATSAPP_TOKEN").value(); | |
const OPENAI_KEY = functions.params | |
.defineString("OPENAI_KEY").value(); | |
const VERIFY_TOKEN = functions.params | |
.defineString("WHATSAPP_WEBHOOK_VERIFY_TOKEN").value(); | |
const COLLECTION_NAME = "whatsapp_messages"; | |
const MESSAGE_TEMPLATE_INTRO = "Hello! Welcome to Qwokka! " + | |
"First let's start with a simple question, " + | |
"are you looking for a movie or tv show?"; | |
const MESSAGE_TEMPLATE_PROMPT = "Great! What are you feeling like? " + | |
"Maybe its a specific vibe like something to watch " + | |
"on a rainy day with your partner?"; | |
const COFFEE_PAGE = "https://www.buymeacoffee.com/qwokkajake"; | |
const COFFEE_PROMPT = "Did you enjoy your recommendation? " + | |
`Consider buying a coffee for my developer: ${COFFEE_PAGE}`; | |
const openAiConfiguration = new Configuration({ | |
apiKey: OPENAI_KEY, | |
}); | |
const openai = new OpenAIApi(openAiConfiguration); | |
/** | |
* Exception to be thrown when we recieve a message twice | |
*/ | |
class MessageAlreadyExistsException { | |
id: string; | |
whatsappId: string; | |
/** | |
* | |
* @param {string} id existing message id in firestore | |
* @param {string} whatsappId whatsapp message id | |
*/ | |
constructor(id: string, whatsappId: string) { | |
this.id = id; | |
this.whatsappId = whatsappId; | |
} | |
} | |
/** | |
* Sends the input, along with the prompt, to openAI | |
* @param {Message[]} messageHistory the chat history to complete | |
*/ | |
async function makeOpenAIRequest( | |
messageHistory: Message[], | |
): Promise<string | undefined> { | |
functions.logger.info("Making openAI request"); | |
const truncatedMessageHistory = messageHistory.length < 6 ? | |
messageHistory : | |
messageHistory.slice(-6); | |
const messages: ChatCompletionRequestMessage[] = [ | |
{ | |
role: ChatCompletionRequestMessageRoleEnum.System, | |
content: | |
"You're an assistant to help discover movies and TV shows to watch."+ | |
"Infuse responses with enthusiasm, witty wordplay, and anecdotes."+ | |
"Make every interaction as entertaining as it is informative."+ | |
"Make sure responses aren't multi-paragraph, but more than 2 lines"+ | |
"Make the user feel like they're talking to a friend."+ | |
"Provide recommendations for recent content,"+ | |
" but don't be afraid to recommend a classic.", | |
}, | |
...truncatedMessageHistory.map(({type, content}) => ({ | |
role: type === "incoming" ? | |
ChatCompletionRequestMessageRoleEnum.User : | |
ChatCompletionRequestMessageRoleEnum.Assistant, | |
content, | |
})), | |
]; | |
try { | |
const res = await openai.createChatCompletion({ | |
model: "gpt-3.5-turbo", | |
messages, | |
temperature: 0.5, | |
max_tokens: 2048, | |
frequency_penalty: 0.0, | |
presence_penalty: 0.0, | |
n: 1, | |
}); | |
functions.logger.debug("Got response from openAI", res); | |
return res.data.choices[0].message?.content; | |
} catch (e) { | |
functions.logger.error("Got error from openAI", e); | |
return undefined; | |
} | |
} | |
/** | |
* Sends a whatsapp message to the specified number | |
* @param {string} phoneNumber Receipient of the message | |
* @param {WhatsappMessage} message The message to send | |
*/ | |
async function sendWhatsappMessage( | |
phoneNumber: string, | |
message: WhatsappMessage, | |
) { | |
functions.logger.info("Sending message", {phoneNumber, message}); | |
try { | |
const res = await axios.post( | |
`https://graph.facebook.com/v17.0/${PHONE_NUMBER_ID}/messages`, | |
JSON.stringify({ | |
messaging_product: "whatsapp", | |
recipient_type: "individual", | |
to: phoneNumber, | |
...message, | |
}), | |
{ | |
headers: { | |
"Authorization": `Bearer ${WHATSAPP_ACCESS_TOKEN}`, | |
"Content-Type": "application/json", | |
}, | |
} | |
); | |
functions.logger.info("Got response from whatsapp API", res); | |
} catch (e) { | |
functions.logger.error("Whatsapp API request failed", e); | |
} | |
} | |
/** | |
* | |
* @param {string} phoneNumber Phone number to send the message to | |
* @param {string} body message to be sent | |
* @param {boolean} skipUpsert should skip adding the message to the db | |
*/ | |
async function sendTextMessage( | |
phoneNumber: string, | |
body: string, | |
skipUpsert?: boolean, | |
) { | |
await sendWhatsappMessage(phoneNumber, { | |
type: "text", | |
text: { | |
preview_url: true, | |
body, | |
}, | |
}); | |
if (!skipUpsert) { | |
await upsertMessageHistory(null, phoneNumber, body, "outgoing"); | |
} | |
} | |
/** | |
* | |
* @param {string} phoneNumber Phone number to send the message to | |
* @param {Button[]} buttons Buttons to be included in the message | |
* @param {string} text message to be sent in the body | |
*/ | |
async function sendButtonMessage( | |
phoneNumber: string, | |
buttons: Button[], | |
text: string, | |
) { | |
await sendWhatsappMessage(phoneNumber, { | |
type: "interactive", | |
interactive: { | |
type: "button", | |
body: {text}, | |
action: { | |
buttons: buttons.map((reply) => ({ | |
type: "reply", | |
reply, | |
})), | |
}, | |
}, | |
}); | |
await upsertMessageHistory(null, phoneNumber, text, "outgoing"); | |
} | |
/** | |
* @param {string | null} whatsappId The message id from whatsapp | |
* @param {string} phoneNumber The phone number to retrieve the history for | |
* @param {string} message The newest message | |
* @param {MessageType} type Whether the new message is incoming or outgoing | |
*/ | |
async function upsertMessageHistory( | |
whatsappId: string | null, | |
phoneNumber: string, | |
message: string, | |
type: MessageType, | |
): Promise<Message[]> { | |
if (type === "incoming" && whatsappId !== null) { | |
const existingMessages = await admin.firestore() | |
.collection(COLLECTION_NAME) | |
.where("whatsappId", "==", whatsappId) | |
.get(); | |
if (existingMessages.docs.length !== 0) { | |
const id = existingMessages.docs[0].id; | |
functions.logger.info( | |
"Message already recieved, not adding", | |
{whatsappId, id}, | |
); | |
throw new MessageAlreadyExistsException(id, whatsappId); | |
} | |
} | |
functions.logger.debug( | |
"Adding in new message", | |
{phoneNumber, text: message, type, whatsappId}, | |
); | |
await admin.firestore().collection(COLLECTION_NAME).add({ | |
phone: phoneNumber, | |
type, | |
message, | |
createdAt: new Date(), | |
whatsappId, | |
}); | |
const records = await admin.firestore() | |
.collection(COLLECTION_NAME) | |
.where("phone", "==", phoneNumber) | |
.orderBy("createdAt", "asc") | |
.get(); | |
functions.logger.debug( | |
"Got message history from firestore", | |
{phoneNumber, numMessages: records.docs.length}, | |
); | |
return records.docs.map((r) => { | |
const {type, message} = r.data(); | |
return {type, content: message}; | |
}); | |
} | |
export const whatsappWebhook = functions.https.onRequest(async (req, res) => { | |
functions.logger.debug("Recieved whatsapp webhook", req.body); | |
const mode = req.query["hub.mode"]; | |
const verify = req.query["hub.verify_token"]; | |
const challenge = req.query["hub.challenge"]; | |
if (mode === "subscribe") { | |
functions.logger.debug("Entered subscribe mode", {verify, challenge, mode}); | |
if (verify === VERIFY_TOKEN) { | |
res.status(200).send(challenge).end(); | |
} else { | |
res.status(403).send().end(); | |
} | |
return; | |
} | |
const entry = req.body.entry; | |
if (entry.length === 0) { | |
functions.logger.warn("No entries found"); | |
res.status(400).send().end(); | |
return; | |
} | |
const changes = entry[entry.length - 1].changes; | |
if (changes.length === 0) { | |
functions.logger.warn("No changes found"); | |
res.status(400).send().end(); | |
return; | |
} | |
const messages = changes[entry.length - 1].value.messages; | |
if (messages.length === 0) { | |
functions.logger.warn("No messages found"); | |
res.status(200).send().end(); | |
return; | |
} | |
const {from, text, type, id, interactive} = messages[messages.length - 1]; | |
if (type === "interactive" && interactive.type === "button_reply") { | |
functions.logger.info( | |
"Recieved interactive message", | |
{from, type, id, interactive}, | |
); | |
const incomingMessage = interactive.button_reply.title; | |
await upsertMessageHistory(id, from, incomingMessage, "incoming"); | |
await sendTextMessage(from, MESSAGE_TEMPLATE_PROMPT); | |
res.status(200).send().end(); | |
return; | |
} else if (type !== "text") { | |
functions.logger.warn("Recieved a non-text message", {messages}); | |
res.status(200).send().end(); | |
return; | |
} | |
functions.logger.info("Valid message found", {from, text}); | |
const messageHistory = await upsertMessageHistory( | |
id, | |
from, | |
text.body, | |
"incoming", | |
); | |
if (messageHistory.length === 1 || text.body === "__RESET__") { | |
functions.logger.debug("New phone number, sending intro message", {from}); | |
await sendButtonMessage( | |
from, | |
[ | |
{title: "Movie", id: "movie"}, | |
{title: "TV Show", id: "tv"}, | |
{title: "Either", id: "either"}, | |
], | |
MESSAGE_TEMPLATE_INTRO, | |
); | |
res.status(200).send().end(); | |
return; | |
} | |
functions.logger.debug( | |
"Existing user, sending to openAI", | |
{from, messageHistory}, | |
); | |
const outgoingMessage = await makeOpenAIRequest(messageHistory); | |
if (outgoingMessage) { | |
functions.logger.info("Sending message", {from, outgoingMessage}); | |
await sendTextMessage(from, outgoingMessage); | |
await sendTextMessage(from, COFFEE_PROMPT, true); | |
} else { | |
functions.logger.warn("No message sent back to user"); | |
} | |
res.status(200).send().end(); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment