diff --git a/_emulator/firebase.json b/_emulator/firebase.json index 0f5728b6..f1c49e66 100644 --- a/_emulator/firebase.json +++ b/_emulator/firebase.json @@ -1,7 +1,8 @@ { "extensions": { "firestore-record-user-acknowledgements": "../firestore-record-user-acknowledgements", - "firestore-bundle-server": "../firestore-bundle-server" + "firestore-bundle-server": "../firestore-bundle-server", + "firestore-dialogflow-fulfillment": "../firestore-dialogflow-fulfillment" }, "storage": { "rules": "storage.rules" @@ -35,7 +36,11 @@ }, "hosting": { "public": "dist", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], "rewrites": [ { "source": "/bundles/*", diff --git a/firestore-dialogflow-fulfillment/.gitignore b/firestore-dialogflow-fulfillment/.gitignore new file mode 100644 index 00000000..4655a0ae --- /dev/null +++ b/firestore-dialogflow-fulfillment/.gitignore @@ -0,0 +1,71 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# 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 + +/functions/lib/* + +# Service file +extensions-testing-firebase.json \ No newline at end of file diff --git a/firestore-dialogflow-fulfillment/CHANGELOG.md b/firestore-dialogflow-fulfillment/CHANGELOG.md new file mode 100644 index 00000000..36474c07 --- /dev/null +++ b/firestore-dialogflow-fulfillment/CHANGELOG.md @@ -0,0 +1,11 @@ +## Version 0.0.3 + +feat: additional training phrase + +## Version 0.0.2 + +fix: Include build sourcecode. + +## Version 0.0.1 + +chore: Initial release diff --git a/firestore-dialogflow-fulfillment/POSTINSTALL.md b/firestore-dialogflow-fulfillment/POSTINSTALL.md new file mode 100644 index 00000000..e928257e --- /dev/null +++ b/firestore-dialogflow-fulfillment/POSTINSTALL.md @@ -0,0 +1,47 @@ +## Setting up DialogFlow Fulfillment + +First, copy this URL, which is the URL of the DialogFlow webhook: `https://${param:LOCATION}-${param:PROJECT_ID}.cloudfunctions.net/ext-${param:EXT_INSTANCE_ID}-dialogflowFulfillment` + +Next, go to the DialogFlow console [here](https://dialogflow.cloud.google.com/#/agent/${param:PROJECT_ID}/fulfillment), enable the Webhook and add this URL as the Fulfillment webhook URL. + +## Give the extension access to your calendar + +To allow the extension to create events, you need to give it access to the calendar `${param:CALENDAR_ID}`. + +To do this, copy the extension service account principal email address: + +``` +ext-${param:EXT_INSTANCE_ID}@extensions-sample.iam.gserviceaccount.com +``` + +which can be found in the [Service Accounts page](https://console.cloud.google.com/iam-admin/serviceaccounts) in the Google Cloud Console. + +Next, go to [your calendar](https://calendar.google.com/calendar/u/0/r/settings) Sharing settings, and share it with the service account email, make sure to give it **Make changes to events** permission. + +## Using the extension + +Call `newConversation` with a `message` to start a new conversation, which will return a `conversationId` that you can use to send messages to the same conversation. + +```ts +export async function newConversation(message: string): Promise { + const result = await httpsCallable<{ message: string }, string>( + functions, + "ext-${param:EXT_INSTANCE_ID}-newConversation" + )({ message }); + return result.data; +} +``` + +To add new messages to the conversation, call `newMessage` with the `conversationId` and `message`. + +```ts +export async function newMessage( + conversationId: string, + message: string +): Promise { + await httpsCallable<{ conversationId: string; message: string }, void>( + functions, + "ext-${param:EXT_INSTANCE_ID}-newMessage" + )({ conversationId, message }); +} +``` diff --git a/firestore-dialogflow-fulfillment/PREINSTALL.md b/firestore-dialogflow-fulfillment/PREINSTALL.md new file mode 100644 index 00000000..e69de29b diff --git a/firestore-dialogflow-fulfillment/README.md b/firestore-dialogflow-fulfillment/README.md new file mode 100644 index 00000000..7b877f82 --- /dev/null +++ b/firestore-dialogflow-fulfillment/README.md @@ -0,0 +1,81 @@ +# Firestore DialogFlow Fulfillment + +This extension integrates with DialogFlow through Firestore, it can book meetings on your calendar from a DialogFlow conversation. + +You don't need to interact with DialogFlow in your app as the extension do it for you and provide the conversation replies from DialogFlow in a Firestore collection. + +## Install The Extension + +Make sure `firebase-tools` is installed: `npm i -g firebase-tools`. + +To install the extension, run: + +```bash +firebase ext:install path/to/extension --project=extensions-testing +``` + +### DialogFlow agent setup + +Before you can use the extension, you need to have a DialogFlow agent. The extension provides you with a HTTP function that you can call to create an agent for you, so you don't to do it manually. + +To create and agent, you can call the `ext-firestore-dialogflow-fulfillment-createDialogflowAgent` function through the [Google Cloud CLI](https://cloud.google.com/sdk/gcloud): + +```bash +gcloud config set project PROJECT_ID +gcloud config set functions/region LOCATION +gcloud functions call ext-firestore-dialogflow-fulfillment-createDialogflowAgent --data '{"data":""}' +``` + +This will create a new DialogFlow agent for you. You can find the agent in the DialogFlow console [here](https://dialogflow.cloud.google.com/#/agents). + +### Setting up DialogFlow Fulfillment + +The extension will deploy a HTTP function that will be used as a Fullfilment webhook for DialogFlow. The function will look like this, with the `PROJECT_ID` replaced with your project ID, and the `LOCATION` replaced with your project's default Cloud Functions location: + +```bash +https://{LOCATION}-{PROJECT_ID}.cloudfunctions.net/ext-firestore-dialogflow-fulfillment-dialogflowFulfillment +``` + +Provide the URL to DialogFlow as the Fulfillment webhook URL. + +### Give the extension access to your calendar + +To allow the extension to create events, you need to give it access to the calendar `${param:CALENDAR_ID}`. To do this, copy the extension service account principal email address from the [Service Accounts page](https://console.cloud.google.com/iam-admin/serviceaccounts) in the Google Cloud Console, which starts with `ext-firestore-dialogflow`. Then, go to your calendar Sharing settings, and add the email with **Make changes to events** permission. + +## Using the extension + +Call `newConversation` with a `message` to start a new conversation, which will return a `conversationId` that you can use to send messages to the same conversation. + +```ts +export async function newConversation(message: string): Promise { + const result = await httpsCallable<{ message: string }, string>( + functions, + "ext-firestore-dialogflow-fulfillment-newConversation" + )({ message }); + return result.data; +} +``` + +To add new messages to the conversation, call `newMessage` with the `conversationId` and `message`. + +```ts +export async function newMessage( + conversationId: string, + message: string +): Promise { + await httpsCallable<{ conversationId: string; message: string }, void>( + functions, + "ext-firestore-dialogflow-fulfillment-newMessage" + )({ conversationId, message }); +} +``` + +## Development + +1. `cd firestore-dialogflow-fulfillment/functions` && `npm run build` +2. `cd firestore-dialogflow-fulfillment/demo` && `npm run dev` +3. `cd _emulator` && `firebase emulators:start` + +(Note: Been using https://ngrok.com/ to pipe the webhook messages to the emulator - just needs installing and setting up on the port functions are running on). + +Use `lsof -t -i:4001 -i:8080 -i:9000 -i:9099 -i:9199 -i:8085 | xargs kill -9` to kill emulators. \ No newline at end of file diff --git a/firestore-dialogflow-fulfillment/demo/.gitignore b/firestore-dialogflow-fulfillment/demo/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/firestore-dialogflow-fulfillment/demo/index.html b/firestore-dialogflow-fulfillment/demo/index.html new file mode 100644 index 00000000..59e67874 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/index.html @@ -0,0 +1,12 @@ + + + + + + Firestore DialogFlow Demo + + +
+ + + diff --git a/firestore-dialogflow-fulfillment/demo/package.json b/firestore-dialogflow-fulfillment/demo/package.json new file mode 100644 index 00000000..557202d8 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/package.json @@ -0,0 +1,23 @@ +{ + "name": "demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "firebase": "^9.14.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.24", + "@types/react-dom": "^18.0.8", + "@vitejs/plugin-react": "^2.2.0", + "typescript": "^4.6.4", + "vite": "^3.2.3" + } +} diff --git a/firestore-dialogflow-fulfillment/demo/src/firebase.ts b/firestore-dialogflow-fulfillment/demo/src/firebase.ts new file mode 100644 index 00000000..df6175d4 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/src/firebase.ts @@ -0,0 +1,60 @@ +import { initializeApp } from "firebase/app"; +import { getAuth, connectAuthEmulator, signInAnonymously } from "firebase/auth"; +import { + getFirestore, + connectFirestoreEmulator, + collection, + onSnapshot, + QuerySnapshot, + DocumentData, + orderBy, + query, +} from "firebase/firestore"; +import { + getFunctions, + connectFunctionsEmulator, + httpsCallable, +} from "firebase/functions"; + +export const app = initializeApp({ + projectId: "extensions-testing", + apiKey: "123", +}); +export const auth = getAuth(app); +export const firestore = getFirestore(app); +export const functions = getFunctions(app); + +connectAuthEmulator(auth, "http://localhost:9099"); +connectFirestoreEmulator(firestore, "localhost", 8080); +connectFunctionsEmulator(functions, "localhost", 5001); + +export function signIn() { + return signInAnonymously(auth); +} + +export async function newConversation(message: string): Promise { + const result = await httpsCallable<{ message: string }, string>( + functions, + "ext-firestore-dialogflow-fulfillment-newConversation" + )({ message }); + return result.data; +} + +export async function newMessage( + conversationId: string, + message: string +): Promise { + await httpsCallable<{ conversationId: string; message: string }, void>( + functions, + "ext-firestore-dialogflow-fulfillment-newMessage" + )({ conversationId, message }); +} + +export function streamMessages( + id: string, + cb: (snapshot: QuerySnapshot) => void +) { + const ref = collection(firestore, "conversations", id, "messages"); + const q = query(ref, orderBy("created_at", "asc")); + return onSnapshot(q, cb); +} diff --git a/firestore-dialogflow-fulfillment/demo/src/main.tsx b/firestore-dialogflow-fulfillment/demo/src/main.tsx new file mode 100644 index 00000000..8f1331c7 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/src/main.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from "react"; +import ReactDOM from "react-dom/client"; +import { + newConversation, + newMessage, + signIn, + streamMessages, +} from "./firebase"; + +signIn().then(() => { + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + ); +}); + +function App() { + const input = React.useRef(null); + const [conversation, setConversation] = React.useState(""); + + if (conversation) { + return ; + } + + return ( + <> + + + + ); +} + +function Conversation(props: { id: string }) { + const id = props.id; + const input = React.useRef(null); + const [messages, setMessages] = React.useState([]); + + useEffect(() => { + return streamMessages(id, (snapshot) => { + const out: any[] = []; + snapshot.docs.forEach((doc) => + out.push({ + ...doc.data(), + id: doc.id, + }) + ); + setMessages(out); + }); + }, [id]); + + return ( + <> +

Conversation:

+
    + {messages.map(({ id, message }: any) => ( +
  • {message}
  • + ))} +
+ + + + ); +} diff --git a/firestore-dialogflow-fulfillment/demo/src/vite-env.d.ts b/firestore-dialogflow-fulfillment/demo/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/firestore-dialogflow-fulfillment/demo/tsconfig.json b/firestore-dialogflow-fulfillment/demo/tsconfig.json new file mode 100644 index 00000000..3d0a51a8 --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/firestore-dialogflow-fulfillment/demo/tsconfig.node.json b/firestore-dialogflow-fulfillment/demo/tsconfig.node.json new file mode 100644 index 00000000..9d31e2ae --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/firestore-dialogflow-fulfillment/demo/vite.config.ts b/firestore-dialogflow-fulfillment/demo/vite.config.ts new file mode 100644 index 00000000..b1b5f91e --- /dev/null +++ b/firestore-dialogflow-fulfillment/demo/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()] +}) diff --git a/firestore-dialogflow-fulfillment/extension.yaml b/firestore-dialogflow-fulfillment/extension.yaml new file mode 100644 index 00000000..1420aaa2 --- /dev/null +++ b/firestore-dialogflow-fulfillment/extension.yaml @@ -0,0 +1,140 @@ +name: firestore-dialogflow-fulfillment +version: 0.0.3 +specVersion: v1beta + +displayName: Firestore DialogFlow Fulfillment +description: TODO + +license: Apache-2.0 # The license you want for the extension +sourceUrl: https://github.com/FirebaseExtended/experimental-extensions/tree/main/firestore-dialogflow-fulfillment + +author: + authorName: Firebase + url: https://firebase.google.com + +billingRequired: true + +apis: + - apiName: dialogflow.googleapis.com + reason: Powers all DialogFlow tasks performed by the extension. + - apiName: calendar-json.googleapis.com + reason: Powers all Calendar tasks performed by the extension. + +roles: + - role: datastore.user + reason: Allows the extension to write to your Firestore Database instance. + - role: dialogflow.admin + reason: Allows the extension to detect and create intents. + +resources: + - name: createDialogflowAgent + type: firebaseextensions.v1beta.function + description: + Create a new agent named ${param:AGENT_NAME} and set-up required intents. + properties: + location: ${param:LOCATION} + runtime: nodejs14 + taskQueueTrigger: {} + - name: newConversation + type: firebaseextensions.v1beta.function + description: todo + properties: + location: ${LOCATION} + runtime: nodejs14 + httpsTrigger: {} + - name: newMessage + type: firebaseextensions.v1beta.function + description: todo + properties: + location: ${LOCATION} + runtime: nodejs14 + httpsTrigger: {} + - name: onNewMessage + type: firebaseextensions.v1beta.function + description: todo + properties: + location: ${LOCATION} + eventTrigger: + eventType: providers/cloud.firestore/eventTypes/document.create + resource: projects/${PROJECT_ID}/databases/(default)/documents/conversations/{conversationId}/messages/{messageId} + - name: dialogflowFulfillment + type: firebaseextensions.v1beta.function + description: TODO + properties: + location: ${LOCATION} + runtime: nodejs14 + httpsTrigger: {} + +lifecycleEvents: + onInstall: + function: createDialogflowAgent + processingMessage: "Create a new agent named ${param:AGENT_NAME} and set-up required intents." + +params: + - param: LOCATION + label: Cloud Functions location + description: >- + Where do you want to deploy the functions created for this extension? + You usually want a location close to your database. Realtime Database + instances are located in `us-central1`. For help selecting a + location, refer to the [location selection + guide](https://firebase.google.com/docs/functions/locations). + type: select + options: + - label: Iowa (us-central1) + value: us-central1 + - label: South Carolina (us-east1) + value: us-east1 + - label: Northern Virginia (us-east4) + value: us-east4 + - label: Belgium (europe-west1) + value: europe-west1 + - label: London (europe-west2) + value: europe-west2 + - label: Frankfurt (europe-west3) + value: europe-west3 + - label: Hong Kong (asia-east2) + value: asia-east2 + - label: Tokyo (asia-northeast1) + value: asia-northeast1 + default: us-central1 + required: true + immutable: true + - param: CALENDAR_ID + label: Calendar ID + description: >- + The ID of the calendar to use for scheduling events. + type: string + default: "" + required: true + - param: DEFAULT_DURATION + label: Default event duration + description: >- + The default duration of events in minutes. + type: string + default: 30 + required: false + - param: LANGUAGE_CODE + label: The language code of the DialogFlow agent. + description: >- + The language code of the DialogFlow agent. + # TODO - add a list of supported languages + type: string + default: "en" + required: false + - param: TIMEZONE + label: The timezone of the event to be scheduled. + description: >- + The timezone of the event to be scheduled. + # TODO - add a list of timezones + type: string + default: "UTC" + required: false + - param: AGENT_NAME + label: The diaplay name of the DialogFlow agent. + description: >- + The diaplay name of the DialogFlow agent. + type: string + required: true + immutable: true + diff --git a/firestore-dialogflow-fulfillment/functions/package.json b/firestore-dialogflow-fulfillment/functions/package.json new file mode 100644 index 00000000..8b69146f --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/package.json @@ -0,0 +1,29 @@ +{ + "name": "firestore-dialogflow-fulfillment", + "scripts": { + "lint": "eslint \"src/**/*\"", + "build": "tsc --watch", + "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", + "generate-readme": "node ../../generate-experimental-readme.js firestore-dialogflow-fulfillment > ../README.md" + }, + "main": "lib/index.js", + "dependencies": { + "@google-cloud/dialogflow": "^5.3.0", + "actions-on-google": "^3.0.0", + "dialogflow-fulfillment-helper": "^0.7.1", + "firebase-admin": "^11.0.1", + "firebase-functions": "^3.24.1", + "googleapis": "^105.0.0", + "@types/express-serve-static-core": "^4.0.50" + }, + "devDependencies": { + "@types/node": "^18.11.9", + "rimraf": "^3.0.2", + "typescript": "^4.8.4" + }, + "private": true +} diff --git a/firestore-dialogflow-fulfillment/functions/src/config.ts b/firestore-dialogflow-fulfillment/functions/src/config.ts new file mode 100644 index 00000000..6ac03e18 --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/src/config.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export default { + projectId: process.env.PROJECT_ID!, + instanceId: process.env.EXT_INSTANCE_ID!, + agentName: process.env.AGENT_NAME!, + defaultDuration: parseInt(process.env.DEFAULT_DURATION ?? "30"), + langugageCode: process.env.LANGUAGE_CODE ?? "en", + timeZone: process.env.TIMEZONE ?? "UTC", + servicePath: "../extensions-testing-firebase.json", +}; diff --git a/firestore-dialogflow-fulfillment/functions/src/index.ts b/firestore-dialogflow-fulfillment/functions/src/index.ts new file mode 100644 index 00000000..95cf6f96 --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/src/index.ts @@ -0,0 +1,454 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as functions from "firebase-functions"; +import * as admin from "firebase-admin"; +import { FieldValue } from "firebase-admin/firestore"; +import { WebhookClient } from "dialogflow-fulfillment-helper"; +import { HttpsError } from "firebase-functions/v1/auth"; +import DialogFlow from "@google-cloud/dialogflow"; +import { calendar_v3, google } from "googleapis"; +import { GaxiosError } from "gaxios"; +import { getExtensions } from "firebase-admin/extensions"; + +import config from "./config"; +import Status from "./types/status"; +import Conversation from "./types/conversation"; +import { extratDate, getDateTimeFormatted } from "./util"; + +admin.initializeApp(); + +const dialogflow = DialogFlow.v2beta1; + +async function createCalendarEvent(dateTime: Date) { + functions.logger.info( + "Authenticating with Google Calendar API for project: " + config.projectId + ); + const auth = new google.auth.GoogleAuth({ + projectId: config.projectId, + scopes: ["https://www.googleapis.com/auth/calendar"], + }); + const client = await auth.getClient(); + + const calendar = google.calendar({ + version: "v3", + auth: client, + }); + + var dateTimeEnd = new Date( + dateTime.getTime() + config.defaultDuration * 60000 + ); + + const event: calendar_v3.Schema$Event = { + summary: "Meeting by DialogFlow", + description: "This is a meeting created by DialogFlow", + start: { + dateTime: dateTime.toISOString(), + timeZone: config.timeZone, + }, + end: { + dateTime: dateTimeEnd.toISOString(), + timeZone: config.timeZone, + }, + attendees: [], + reminders: { + useDefault: false, + overrides: [ + { method: "email", minutes: 24 * 60 }, + { method: "popup", minutes: 10 }, + ], + }, + }; + + try { + functions.logger.info("Inserting a new event for: " + config.projectId); + + await calendar.events.insert({ + requestBody: event, + calendarId: process.env.CALENDAR_ID, + }); + } catch (error) { + if ( + error instanceof GaxiosError && + error.code && + parseInt(error.code) === 404 + ) { + throw new HttpsError("not-found", "Calendar not found"); + } else { + functions.logger.error(error); + throw error; + } + } +} + +exports.newConversation = functions.https.onCall(async (data, ctx) => { + if (!ctx.auth) { + throw new HttpsError( + "unauthenticated", + "The function must be called while authenticated." + ); + } + + const { uid } = ctx.auth; + const { message } = data; + + if (!message) { + throw new HttpsError( + "invalid-argument", + "The function must be called with a message." + ); + } + + const ref = admin.firestore().collection("conversations").doc(); + + const batch = admin.firestore().bulkWriter(); + + batch.create(ref, { + users: [uid], + started_at: FieldValue.serverTimestamp(), + updated_at: FieldValue.serverTimestamp(), + message_count: 0, + }); + + batch.create(ref.collection("messages").doc(), { + type: "USER", + uid: uid, + created_at: FieldValue.serverTimestamp(), + message, + }); + + await batch.close(); + + return ref.id; +}); + +exports.newMessage = functions.https.onCall(async (data, ctx) => { + if (!ctx.auth) { + throw new HttpsError( + "unauthenticated", + "The function must be called while authenticated." + ); + } + + const { uid } = ctx.auth; + const { conversationId, message } = data; + + if (!conversationId || !message) { + throw new HttpsError( + "invalid-argument", + "The function must be called with a conversationId and message." + ); + } + + const ref = admin.firestore().collection("conversations").doc(conversationId); + const snapshot = await ref.get(); + + if (!snapshot.exists) { + throw new HttpsError("not-found", "The conversation does not exist."); + } + + const { users } = snapshot.data() as Conversation; + + if (!users.includes(uid)) { + throw new HttpsError( + "permission-denied", + "The user is not part of the conversation." + ); + } + + await ref.collection("messages").doc().create({ + type: "USER", + status: Status.PENDING, + uid: uid, + created_at: FieldValue.serverTimestamp(), + message, + }); +}); + +exports.onNewMessage = functions.firestore + .document("conversations/{conversationId}/{messageCollectionId}/{messageId}") + .onCreate(async (change, ctx) => { + const { conversationId } = ctx.params; + const { type, message, uid } = change.data() as any; // TODO: add types + const ref = admin + .firestore() + .collection("conversations") + .doc(conversationId); + let finalized = false; + + await ref.update({ + updated_at: FieldValue.serverTimestamp(), + message_count: FieldValue.increment(1), + }); + + const auth = new google.auth.GoogleAuth({ + projectId: config.projectId, + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/dialogflow", + ], + }); + + if (type === "USER") { + const sessionClient = new dialogflow.SessionsClient({ + auth: auth, + }); + + const sessionPath = sessionClient.projectAgentSessionPath( + config.projectId, + conversationId + ); + + const request = { + session: sessionPath, + queryInput: { + text: { + languageCode: config.langugageCode, + text: message, + }, + }, + queryParams: { + timeZone: config.timeZone, + uid: uid, + }, + }; + + try { + var [intent] = await sessionClient.detectIntent(request); + + const batch = admin.firestore().bulkWriter(); + + batch.update(change.ref, { + status: Status.SUCCESS, + }); + + if (intent.queryResult?.fulfillmentText) { + batch.create(ref.collection("messages").doc(), { + type: "BOT", + status: Status.SUCCESS, + created_at: admin.firestore.FieldValue.serverTimestamp(), + message: intent.queryResult.fulfillmentText, + }); + + if (intent.queryResult.intent?.displayName === "intent.calendar") { + finalized = true; + } + } else { + batch.create(ref.collection("messages").doc(), { + type: "BOT", + status: Status.SUCCESS, + created_at: admin.firestore.FieldValue.serverTimestamp(), + message: "Response from DialogFlow", + }); + } + + if (finalized) { + batch.update(ref, { + status: Status.COMPLETE, + }); + } else { + batch.update(ref, { + status: Status.PENDING, + }); + } + + await batch.close(); + } catch (error) { + functions.logger.error(error); + } + } + }); + +exports.dialogflowFulfillment = functions.https.onRequest( + async (request, response) => { + const agent: any = new WebhookClient({ request, response }); + const intents = new Map(); + + intents.set("ext.fallback", (agent: any) => { + agent.add("fallback response..."); + }); + + intents.set( + `ext-${config.instanceId}.intent.calendar`, + async (agent: any) => { + const { parameters } = agent; + + if (parameters?.DATE && parameters?.TIME) { + const dateTime = extratDate(parameters.DATE, parameters.TIME); + const dateTimeFormatted = getDateTimeFormatted(dateTime); + try { + await createCalendarEvent(dateTime); + agent.add( + `You are all set for ${dateTimeFormatted}. See you then!` + ); + } catch (error) { + if (error instanceof HttpsError && error.code === "not-found") { + agent.add(`Sorry, I couldn't find your calendar.`); + } else { + agent.add( + `I'm sorry, there are no slots available for ${dateTimeFormatted}.` + ); + } + } + } + } + ); + + return agent.handleRequest(intents); + } +); + +exports.createDialogflowAgent = functions.tasks + .taskQueue() + .onDispatch(async () => { + const runtime = getExtensions().runtime(); + const auth = new google.auth.GoogleAuth({ + projectId: config.projectId, + scopes: "https://www.googleapis.com/auth/dialogflow", + }); + + const agent = new dialogflow.AgentsClient({ + auth: auth, + }); + + const intentsClient = new dialogflow.IntentsClient({ + auth: auth, + }); + + try { + functions.logger.info(`Creating Dialogflow agent ${config.agentName}...`); + + await agent.setAgent({ + agent: { + parent: `projects/${config.projectId}/locations/global`, + displayName: config.agentName, + timeZone: config.timeZone, + supportedLanguageCodes: [config.langugageCode], + }, + }); + } catch (error) { + functions.logger.error(error); + } + + try { + const agentPath = intentsClient.projectAgentPath(config.projectId); + await intentsClient.createIntent({ + parent: agentPath, + intent: { + displayName: `ext-${config.instanceId}.intent.calendar`, + messages: [ + { + text: { + text: ["You are all set for $date at $time. See you then!"], + }, + }, + ], + parameters: [ + { + displayName: "DATE", + entityTypeDisplayName: "@sys.date", + mandatory: true, + value: "$date", + prompts: [ + "What date would you like to schedule the appointment?", + ], + }, + { + displayName: "TIME", + entityTypeDisplayName: "@sys.time", + mandatory: true, + value: "$time", + prompts: [ + "What time would you like to schedule the appointment?", + ], + }, + ], + webhookState: "WEBHOOK_STATE_ENABLED", + trainingPhrases: [ + { + type: "EXAMPLE", + parts: [ + { + text: "Set an appointment on ", + }, + { + text: "Wednesday", + entityType: "@sys.date", + }, + { + text: " at ", + }, + { + text: "2 PM", + entityType: "@sys.time", + }, + ], + }, + { + type: "EXAMPLE", + parts: [ + { + text: "I have a meeting ", + }, + { + text: "tomorrow", + entityType: "@sys.date", + }, + { + text: " at ", + }, + { + text: "9 PM", + entityType: "@sys.time", + }, + ], + }, + { + type: "EXAMPLE", + parts: [ + { + text: "I have a meeting ", + }, + { + text: "today", + entityType: "@sys.date", + }, + { + text: " at ", + }, + { + text: "9 PM", + entityType: "@sys.time", + }, + ], + }, + ], + }, + }); + + await runtime.setProcessingState( + "PROCESSING_COMPLETE", + `Successfully creeated a new agent named ${config.agentName}.` + ); + } catch (error) { + functions.logger.error(error); + + await runtime.setProcessingState( + "PROCESSING_FAILED", + `Agent ${config.agentName} wasn't created.` + ); + } + }); diff --git a/firestore-dialogflow-fulfillment/functions/src/types/conversation.ts b/firestore-dialogflow-fulfillment/functions/src/types/conversation.ts new file mode 100644 index 00000000..99661859 --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/src/types/conversation.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Status from "./status"; + +export default interface Conversation { + message_count: number; + started_at: Date; + updated_at: Date; + status: Status; + users: string[]; +} diff --git a/firestore-dialogflow-fulfillment/functions/src/types/status.ts b/firestore-dialogflow-fulfillment/functions/src/types/status.ts new file mode 100644 index 00000000..971db145 --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/src/types/status.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +enum Status { + PENDING = "PENDING", + SUCCESS = "SUCCESS", + COMPLETE = "COMPLETE", + ERROR = "ERROR", +} + +export default Status; diff --git a/firestore-dialogflow-fulfillment/functions/src/util.ts b/firestore-dialogflow-fulfillment/functions/src/util.ts new file mode 100644 index 00000000..d9ec1b8c --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/src/util.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Extract Date and Time from Dialogflow parameters. + * + * @param dateParam The string to parse to a date. + * @param timeParam The string to parse to a time. + * @returns Date object representing the date and time. + */ +export function extratDate(dateParam: string, timeParam: string) { + try { + var date = new Date(dateParam); + var time = new Date(timeParam); + var dateTime = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + time.getHours(), + time.getMinutes() + ); + + return dateTime; + } catch (error) { + throw new Error("Invalid date or time string."); + } +} + +/** + * Format Date and Time to a readable string. + * @param dateTime Date object representing the date and time. + * @returns Formatted date and time string. + */ +export function getDateTimeFormatted(dateTime: Date) { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + weekday: "long", + }).format(dateTime); +} diff --git a/firestore-dialogflow-fulfillment/functions/tsconfig.json b/firestore-dialogflow-fulfillment/functions/tsconfig.json new file mode 100644 index 00000000..a9ed863a --- /dev/null +++ b/firestore-dialogflow-fulfillment/functions/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "compileOnSave": true, + "include": ["src"] +}