diff --git a/packages/doorman-api/src/functions/api/door/logCall.ts b/packages/doorman-api/src/functions/api/door/logCall.ts new file mode 100644 index 0000000..30764d4 --- /dev/null +++ b/packages/doorman-api/src/functions/api/door/logCall.ts @@ -0,0 +1,70 @@ +/** + * Try to get door info + */ + +import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types"; +import { TwilioContext } from "../../../types/TwilioContext"; +import { createDynaBridgeClient } from "../../../utils/ddb"; +import { ONBOARDING_DOOR_NAME } from "../../../schema/DoorConfig"; +import { getLockStatusID, isLockOpen } from "../../../schema/LockStatus"; +import { withMetrics } from "../../../common/DoormanHandler"; +import { z } from "zod"; +import { UserAgentHeader } from "../../../utils/blockUserAgent"; +import { setResponseJson } from "../../../utils/responseUtils"; +import { LOG_CALL_SK, LogCallSchema } from "../../../schema/LogCall"; + +export const LogCallRequestSchema = z.object({ + caller: z.string(), +}); + +export type LogCallRequest = z.infer; +export interface LogCallRequestTwilio extends ServerlessEventObject { }; + +export const LogCallResponseSchema = z.object({ + otp: z.string(), +}); + +export type LogCallResponse = z.infer; + +function getCode() { + return `${Math.floor(Math.random() * 10000)}`.padStart(4, '0'); +}; + +export const handler: ServerlessFunctionSignature = withMetrics("logCall", async (context, event, callback, metricsRegistry) => { + const response = new Twilio.Response(); + const req = LogCallRequestSchema.parse(event); + + let caller = req.caller; + + const db = createDynaBridgeClient(context); + + // check if onboarding is actually open + const lock = await db.entities.lockStatus.findById(getLockStatusID(ONBOARDING_DOOR_NAME)); + + if (!isLockOpen(lock)) { + setResponseJson(response, 400, { + msg: "Onboarding is not open", + }); + } else { + // log this caller + const otp = getCode(); + const logCall = LogCallSchema.parse({ + PK: otp, + SK: LOG_CALL_SK, + caller, + TTL: Date.now() + 60 * 60 * 1000, // 60 minutes from now + }); + + await db.entities.logCall.save(logCall); + + setResponseJson(response, 200, { + otp, + }); + } + + // destroy the internal client after + // @ts-ignore + db.ddbClient.destroy(); + + return callback(null, response); +}); diff --git a/packages/doorman-api/src/functions/api/door/status.ts b/packages/doorman-api/src/functions/api/door/status.ts index 9505b58..559ec10 100644 --- a/packages/doorman-api/src/functions/api/door/status.ts +++ b/packages/doorman-api/src/functions/api/door/status.ts @@ -50,10 +50,7 @@ export const handler: ServerlessFunctionSignature { + return [otp, LOG_CALL_SK]; +} + +export type LogCall = z.infer; + +export const LogCallEntity: DynaBridgeEntity = { + tableName: "doorman", + id: ["PK", "SK"], +}; diff --git a/packages/doorman-api/src/utils/ddb.ts b/packages/doorman-api/src/utils/ddb.ts index 9f87ed9..9408c8c 100644 --- a/packages/doorman-api/src/utils/ddb.ts +++ b/packages/doorman-api/src/utils/ddb.ts @@ -4,6 +4,7 @@ import { DoorConfigEntity, EditDoorConfigEntity, OnboardDoorConfigEntity } from import { DoorAliasEntity } from "../schema/DoorAlias"; import { LockStatusEntity } from "../schema/LockStatus"; import { DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; +import { LogCallEntity } from "../schema/LogCall"; export const createDynaBridgeClient = (context: TwilioContext) => { let config: DynamoDBClientConfig = { @@ -28,6 +29,7 @@ export const createDynaBridgeClient = (context: TwilioContext) => { doorAlias: DoorAliasEntity, lockStatus: LockStatusEntity, onboardDoorConfig: OnboardDoorConfigEntity, + logCall: LogCallEntity }, { serialize: (entity) => entity, deserialize: (entity) => { diff --git a/packages/doorman-api/tst/integ-staging.test.ts b/packages/doorman-api/tst/integ-staging.test.ts index ba7f97c..0d7d2d6 100644 --- a/packages/doorman-api/tst/integ-staging.test.ts +++ b/packages/doorman-api/tst/integ-staging.test.ts @@ -50,15 +50,15 @@ describe("onboardingflag door should exist", () => { expect(door.status).toBe(DoorStatus.CLOSED); }); - test("onboarding door does not close after status check", async () => { + test("onboarding door does not close after info check", async () => { let authResp = await fetch(baseUrl + `/api/door/auth?door=${ONBOARDING_DOOR_NAME}&key=${ONBOARDING_DOOR_PIN}`); expect(authResp.status).toBe(200); - let statusResp = await fetch(baseUrl + `/api/door/status?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as StatusResponse; + let statusResp = await fetch(baseUrl + `/api/door/info?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as InfoResponseUI; expect(statusResp.status).toBe(DoorStatus.OPEN); // open again - statusResp = await fetch(baseUrl + `/api/door/status?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as StatusResponse; + statusResp = await fetch(baseUrl + `/api/door/info?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as InfoResponseUI; expect(statusResp.status).toBe(DoorStatus.OPEN); // close it diff --git a/packages/doorman-client/src/functions/buzzer-activated.ts b/packages/doorman-client/src/functions/buzzer-activated.ts index 19f255c..5c10752 100644 --- a/packages/doorman-client/src/functions/buzzer-activated.ts +++ b/packages/doorman-client/src/functions/buzzer-activated.ts @@ -8,8 +8,8 @@ import { ServerlessFunctionSignature, } from '@twilio-labs/serverless-runtime-types/types'; import { BuzzerDialEvent } from '../types/BuzzerDialEvent'; -import { getConfig, notifyAllDiscord, notifyDiscord } from '../utils/DoormanUtils'; -import { dialFallbackTwiml, doorOpenTwiml } from '../utils/TwimlUtils'; +import { getConfig, notifyAllDiscord, notifyDiscord, tryLogCallerForOnboarding } from '../utils/DoormanUtils'; +import { dialFallbackTwiml, doorOnboardTwiml, doorOpenTwiml } from '../utils/TwimlUtils'; import { TwilioContext } from '../types/TwilioContext'; import VoiceResponse from 'twilio/lib/twiml/VoiceResponse'; import { DoorStatus } from '../../../doorman-api/src/types/DoorStatus'; @@ -39,15 +39,31 @@ export const handler: ServerlessFunctionSignature(metricsRegistry, BuzzerActivatedMetrics.CALL_REJECTED) - .inc({ From: event.From }, 1); - callback(null, twiml); - return; + // onboarding is enabled, we got OTP to speak to user + if (callLog.otp) { + console.log(invokeId + " going to speak OTP to caller"); + getMetricFromRegistry(metricsRegistry, BuzzerActivatedMetrics.ONBOARDING_ATTEMPT) + .inc({ From: event.From }, 1); + + const response = doorOnboardTwiml(callLog.otp); + + callback(null, response); + return; + } else { + // no onboarding, and this is an unknown caller. Just reject + let twiml = new Twilio.twiml.VoiceResponse(); + twiml.reject(); + + getMetricFromRegistry(metricsRegistry, BuzzerActivatedMetrics.CALL_REJECTED) + .inc({ From: event.From }, 1); + callback(null, twiml); + return; + } } // let users know someone is currently buzzing, and allow unlock by discord user diff --git a/packages/doorman-client/src/metrics/BuzzerActivatedMetrics.ts b/packages/doorman-client/src/metrics/BuzzerActivatedMetrics.ts index 2ca24a0..3a475a2 100644 --- a/packages/doorman-client/src/metrics/BuzzerActivatedMetrics.ts +++ b/packages/doorman-client/src/metrics/BuzzerActivatedMetrics.ts @@ -8,6 +8,7 @@ export enum BuzzerActivatedMetrics { POLL_ATTEMPTS = "PollAttempts", POLL_LATENCY = "PollLatency", NOTIFY_LATENCY = "NotifyLatency", + ONBOARDING_ATTEMPT = "OnboardingAttempt", } export const registerMetrics = (metricsRegistry: Registry) => { @@ -17,6 +18,12 @@ export const registerMetrics = (metricsRegistry: Registry) => { labelNames: ["From"], })); + metricsRegistry.registerMetric(new Counter({ + name: BuzzerActivatedMetrics.ONBOARDING_ATTEMPT, + help: "A non registered number attempted onboarding", + labelNames: ["From"], + })); + metricsRegistry.registerMetric(new Counter({ name: BuzzerActivatedMetrics.API_UNLOCK, help: "Door was unlocked with the API", diff --git a/packages/doorman-client/src/utils/DoormanUtils.ts b/packages/doorman-client/src/utils/DoormanUtils.ts index 57268c4..f456a1c 100644 --- a/packages/doorman-client/src/utils/DoormanUtils.ts +++ b/packages/doorman-client/src/utils/DoormanUtils.ts @@ -1,8 +1,10 @@ import { register, Registry, Summary } from "prom-client"; -import { InfoResponseClient } from "../../../doorman-api/src/functions/api/door/info"; +import { InfoResponseClient, InfoResponseUI } from "../../../doorman-api/src/functions/api/door/info"; import { TwilioContext } from "../types/TwilioContext"; import { lambdaFastHttp } from "./LambdaUtils"; import { BuzzerActivatedMetrics } from "../metrics/BuzzerActivatedMetrics"; +import { ONBOARDING_DOOR_NAME } from "../../../doorman-api/src/schema/DoorConfig"; +import { LogCallResponse } from "../../../doorman-api/src/functions/api/door/logCall"; export async function getConfig(context: TwilioContext, buzzer: string): Promise { return await fetch(context.DOORMAN_URL + `/api/door/info?buzzer=${buzzer}`) @@ -12,9 +14,17 @@ export async function getConfig(context: TwilioContext, buzzer: string): Promise }) as InfoResponseClient; } -export async function notifyDiscord(context: TwilioContext, msg: string[], u: string[], optionalJsonStr: string[], metricsRegistry: Registry){ +export async function tryLogCallerForOnboarding(context: TwilioContext, caller: string): Promise { + return await fetch(context.DOORMAN_URL + `/api/door/logCall?caller=${caller}`) + .then(res => res.json()) + .catch(err => { + return undefined; + }) as LogCallResponse; +} + +export async function notifyDiscord(context: TwilioContext, msg: string[], u: string[], optionalJsonStr: string[], metricsRegistry: Registry) { const endTimer = (metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.NOTIFY_LATENCY) as Summary).startTimer(); - const res = await lambdaFastHttp(context.DOORMAN_URL + + const res = await lambdaFastHttp(context.DOORMAN_URL + `/api/door/notify?discordUser=${encodeURIComponent(JSON.stringify(u))}&msg=${encodeURIComponent(JSON.stringify(msg))}&json=${encodeURIComponent(JSON.stringify(optionalJsonStr))}&key=${context.NOTIFY_SECRET_KEY}`, ).catch(err => console.log(err)); endTimer(); diff --git a/packages/doorman-client/src/utils/TwimlUtils.ts b/packages/doorman-client/src/utils/TwimlUtils.ts index 839b653..dfaf54c 100644 --- a/packages/doorman-client/src/utils/TwimlUtils.ts +++ b/packages/doorman-client/src/utils/TwimlUtils.ts @@ -3,10 +3,10 @@ import { InfoResponseClient } from '../../../doorman-api/src/functions/api/door/ export function doorOpenTwiml(config: InfoResponseClient): VoiceResponse { const twiml = new Twilio.twiml.VoiceResponse(); - + // play audio twiml.play('https://buzzer-2439-prod.twil.io/buzzing_up_boosted.mp3'); - + // press digit twiml.play({ digits: config.pressKey }); // configured in doorman what button to click and passed into this function @@ -24,7 +24,7 @@ export function dialFallbackTwiml(config: InfoResponseClient): VoiceResponse { const twiml = new Twilio.twiml.VoiceResponse(); let dial = twiml.dial({ - timeLimit: 20, + timeLimit: 20, timeout: 20 }); @@ -35,3 +35,36 @@ export function dialFallbackTwiml(config: InfoResponseClient): VoiceResponse { // @ts-ignore return twiml; } + +export function doorOnboardTwiml(otp: string): VoiceResponse { + const twiml = new Twilio.twiml.VoiceResponse(); + const prefix = 'https://buzzer-2439-prod.twil.io/'; + + // play intro + twiml.play(prefix + 'onboarding-intro.mp3'); + + twiml.pause({ length: 3 }); + + // play digits audio + otp.split('').forEach(digit => { + twiml.play(prefix + digit + ".mp3"); + }); + + twiml.pause({ length: 3 }); + + // play repeat + twiml.play(prefix + 'onboarding-repeat.mp3'); + + twiml.pause({ length: 3 }); + + // play digits audio + otp.split('').forEach(digit => { + twiml.play(prefix + digit + ".mp3"); + }); + + twiml.pause({ length: 3 }); + + // @ts-ignore + return twiml; +} +