Skip to content

Instantly share code, notes, and snippets.

@jakebloom
Last active October 4, 2023 08:42
Show Gist options
  • Save jakebloom/2d8468229eb40b99b72e039fd2150831 to your computer and use it in GitHub Desktop.
Save jakebloom/2d8468229eb40b99b72e039fd2150831 to your computer and use it in GitHub Desktop.
Firebase function for a whatsapp bot that recommends movies and tv shows
/**
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