Compare commits

...

3 Commits

Author SHA1 Message Date
13e7cde723 add onboarding test to local
Some checks failed
Build and push image for doorman-homeassistant / docker (push) Successful in 43s
Build and push Doorman UI / API / docker (push) Failing after 49s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 5s
2025-10-04 13:34:00 -07:00
01243ce96c add onboarding flow to buzzer client 2025-10-04 13:22:38 -07:00
4975945306 add new audio assets 2025-10-04 13:18:34 -07:00
24 changed files with 228 additions and 28 deletions

View File

@ -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<typeof LogCallRequestSchema>;
export interface LogCallRequestTwilio extends ServerlessEventObject<LogCallRequest, UserAgentHeader> { };
export const LogCallResponseSchema = z.object({
otp: z.string(),
});
export type LogCallResponse = z.infer<typeof LogCallResponseSchema>;
function getCode() {
return `${Math.floor(Math.random() * 10000)}`.padStart(4, '0');
};
export const handler: ServerlessFunctionSignature<TwilioContext, LogCallRequestTwilio> = 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);
});

View File

@ -50,10 +50,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, StatusRequestTw
setResponseJson(response, 200, body); setResponseJson(response, 200, body);
// this hardcoded door, keep open for timeout await db.entities.lockStatus.deleteById(getLockStatusID(door));
if (door !== ONBOARDING_DOOR_NAME) {
await db.entities.lockStatus.deleteById(getLockStatusID(door));
}
} else { } else {
const body = StatusResponseSchema.parse({ const body = StatusResponseSchema.parse({
status: DoorStatus.CLOSED, status: DoorStatus.CLOSED,

View File

@ -0,0 +1,23 @@
import { z } from "zod";
import { DynaBridgeEntity } from 'dynabridge';
export const LOG_CALL_SK = "log-call";
export const LogCallSchema = z.object({
// keys
PK: z.string(), // OTP
SK: z.literal(LOG_CALL_SK).default(LOG_CALL_SK),
caller: z.string(),
TTL: z.number(),
});
export const getLogCallID = (otp: string): string[] => {
return [otp, LOG_CALL_SK];
}
export type LogCall = z.infer<typeof LogCallSchema>;
export const LogCallEntity: DynaBridgeEntity<LogCall> = {
tableName: "doorman",
id: ["PK", "SK"],
};

View File

@ -4,6 +4,7 @@ import { DoorConfigEntity, EditDoorConfigEntity, OnboardDoorConfigEntity } from
import { DoorAliasEntity } from "../schema/DoorAlias"; import { DoorAliasEntity } from "../schema/DoorAlias";
import { LockStatusEntity } from "../schema/LockStatus"; import { LockStatusEntity } from "../schema/LockStatus";
import { DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; import { DynamoDBClientConfig } from "@aws-sdk/client-dynamodb";
import { LogCallEntity } from "../schema/LogCall";
export const createDynaBridgeClient = (context: TwilioContext) => { export const createDynaBridgeClient = (context: TwilioContext) => {
let config: DynamoDBClientConfig = { let config: DynamoDBClientConfig = {
@ -28,6 +29,7 @@ export const createDynaBridgeClient = (context: TwilioContext) => {
doorAlias: DoorAliasEntity, doorAlias: DoorAliasEntity,
lockStatus: LockStatusEntity, lockStatus: LockStatusEntity,
onboardDoorConfig: OnboardDoorConfigEntity, onboardDoorConfig: OnboardDoorConfigEntity,
logCall: LogCallEntity
}, { }, {
serialize: (entity) => entity, serialize: (entity) => entity,
deserialize: (entity) => { deserialize: (entity) => {

View File

@ -3,7 +3,8 @@ import { waitForService, baseUrl, doorName, buzzerNumber, key, buzzerUrl } from
import { DoorStatus } from "../src/types/DoorStatus"; import { DoorStatus } from "../src/types/DoorStatus";
import { StatusResponse } from "../src/functions/api/door/status"; import { StatusResponse } from "../src/functions/api/door/status";
import { sleep } from "bun"; import { sleep } from "bun";
import { InfoResponseClient } from "../src/functions/api/door/info"; import { ONBOARDING_DOOR_NAME, ONBOARDING_DOOR_PIN } from "../src/schema/DoorConfig";
import { LogCallResponse } from "../src/functions/api/door/logCall";
// these tests should only run locally // these tests should only run locally
if (process.env.STAGE === 'staging') { if (process.env.STAGE === 'staging') {
@ -53,7 +54,7 @@ describe("unlock path works", () => {
// unlock door with wrong code should be 401 // unlock door with wrong code should be 401
const badAuthResp = await fetch(baseUrl + `/api/door/auth?door=${doorName}&key=thisisthewrongkey`); const badAuthResp = await fetch(baseUrl + `/api/door/auth?door=${doorName}&key=thisisthewrongkey`);
expect(badAuthResp.status).toBe(401); expect(badAuthResp.status).toBe(401);
// unlock door with correct code should be 200 // unlock door with correct code should be 200
const authResp = await fetch(baseUrl + `/api/door/auth?door=${doorName}&key=${key}`); const authResp = await fetch(baseUrl + `/api/door/auth?door=${doorName}&key=${key}`);
expect(authResp.status).toBe(200); expect(authResp.status).toBe(200);
@ -73,15 +74,56 @@ describe("unlock path works", () => {
test("auth works for timeout", async () => { test("auth works for timeout", async () => {
// run status first, to make sure we are closed // run status first, to make sure we are closed
const statusReset = await fetch(baseUrl + `/api/door/status?door=${doorName}`).then(res => res.json()) as StatusResponse; const statusReset = await fetch(baseUrl + `/api/door/status?door=${doorName}`).then(res => res.json()) as StatusResponse;
// run auth with timeout specified // run auth with timeout specified
const authResp = await fetch(baseUrl + `/api/door/auth?door=${doorName}&key=${key}&timeout=1`); const authResp = await fetch(baseUrl + `/api/door/auth?door=${doorName}&key=${key}&timeout=1`);
// sleep 1s // sleep 1s
await sleep(1_000); await sleep(1_000);
// we should be closed, because we passed the timeout // we should be closed, because we passed the timeout
const infoClosed = await fetch(baseUrl + `/api/door/info?door=${doorName}`).then(res => res.json()) as any; const infoClosed = await fetch(baseUrl + `/api/door/info?door=${doorName}`).then(res => res.json()) as any;
expect(infoClosed.status).toBe(DoorStatus.CLOSED); expect(infoClosed.status).toBe(DoorStatus.CLOSED);
});
});
describe("call log path works", () => {
test("call log returns nothing when onboarding is not enabled", async () => {
// run status first, to make sure we are closed
const statusReset = await fetch(baseUrl + `/api/door/status?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as StatusResponse;
// try to log call
const logCallRes = await fetch(baseUrl + `/api/door/callLog?caller=${buzzerNumber}`);
expect(logCallRes.status).toBe(400);
});
test("call log returns 4 digit OTP when onboarding enabled", async () => {
// run status first, to make sure we are closed
const statusReset = await fetch(baseUrl + `/api/door/status?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as StatusResponse;
// run auth with timeout specified
const authResp = await fetch(baseUrl + `/api/door/auth?door=${ONBOARDING_DOOR_NAME}&key=${ONBOARDING_DOOR_PIN}`);
// try to log call
const logCallRes = await fetch(baseUrl + `/api/door/callLog?caller=${buzzerNumber}`);
expect(logCallRes.status).toBe(200);
const otp = (await logCallRes.json() as LogCallResponse).otp
expect(otp.length).toBe(4)
});
test("call log after door closed, should not return OTP", async () => {
// run status first, to make sure we are closed
const statusReset = await fetch(baseUrl + `/api/door/status?door=${ONBOARDING_DOOR_NAME}`).then(res => res.json()) as StatusResponse;
// run auth with timeout specified
const authResp = await fetch(baseUrl + `/api/door/auth?door=${ONBOARDING_DOOR_NAME}&key=${ONBOARDING_DOOR_PIN}&timeout=1`);
// sleep 1s
await sleep(1_000);
// try to log call
const logCallRes = await fetch(baseUrl + `/api/door/callLog?caller=${buzzerNumber}`);
expect(logCallRes.status).toBe(400);
}); });
}); });

View File

@ -50,15 +50,15 @@ describe("onboardingflag door should exist", () => {
expect(door.status).toBe(DoorStatus.CLOSED); 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}`); let authResp = await fetch(baseUrl + `/api/door/auth?door=${ONBOARDING_DOOR_NAME}&key=${ONBOARDING_DOOR_PIN}`);
expect(authResp.status).toBe(200); 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); expect(statusResp.status).toBe(DoorStatus.OPEN);
// open again // 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); expect(statusResp.status).toBe(DoorStatus.OPEN);
// close it // close it

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,8 +8,8 @@ import {
ServerlessFunctionSignature, ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'; } from '@twilio-labs/serverless-runtime-types/types';
import { BuzzerDialEvent } from '../types/BuzzerDialEvent'; import { BuzzerDialEvent } from '../types/BuzzerDialEvent';
import { getConfig, notifyAllDiscord, notifyDiscord } from '../utils/DoormanUtils'; import { getConfig, notifyAllDiscord, notifyDiscord, tryLogCallerForOnboarding } from '../utils/DoormanUtils';
import { dialFallbackTwiml, doorOpenTwiml } from '../utils/TwimlUtils'; import { dialFallbackTwiml, doorOnboardTwiml, doorOpenTwiml } from '../utils/TwimlUtils';
import { TwilioContext } from '../types/TwilioContext'; import { TwilioContext } from '../types/TwilioContext';
import VoiceResponse from 'twilio/lib/twiml/VoiceResponse'; import VoiceResponse from 'twilio/lib/twiml/VoiceResponse';
import { DoorStatus } from '../../../doorman-api/src/types/DoorStatus'; import { DoorStatus } from '../../../doorman-api/src/types/DoorStatus';
@ -39,15 +39,31 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
} }
} }
// reject the call if this is not configured // if this is not configured
if (!config || !config.door) { if (!config || !config.door) {
let twiml = new Twilio.twiml.VoiceResponse(); // check if onboarding is enabled
twiml.reject(); const callLog = await tryLogCallerForOnboarding(context, event.From);
getMetricFromRegistry<Counter>(metricsRegistry, BuzzerActivatedMetrics.CALL_REJECTED) // onboarding is enabled, we got OTP to speak to user
.inc({ From: event.From }, 1); if (callLog.otp) {
callback(null, twiml); console.log(invokeId + " going to speak OTP to caller");
return; getMetricFromRegistry<Counter>(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<Counter>(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 // let users know someone is currently buzzing, and allow unlock by discord user

View File

@ -8,6 +8,7 @@ export enum BuzzerActivatedMetrics {
POLL_ATTEMPTS = "PollAttempts", POLL_ATTEMPTS = "PollAttempts",
POLL_LATENCY = "PollLatency", POLL_LATENCY = "PollLatency",
NOTIFY_LATENCY = "NotifyLatency", NOTIFY_LATENCY = "NotifyLatency",
ONBOARDING_ATTEMPT = "OnboardingAttempt",
} }
export const registerMetrics = (metricsRegistry: Registry) => { export const registerMetrics = (metricsRegistry: Registry) => {
@ -17,6 +18,12 @@ export const registerMetrics = (metricsRegistry: Registry) => {
labelNames: ["From"], labelNames: ["From"],
})); }));
metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.ONBOARDING_ATTEMPT,
help: "A non registered number attempted onboarding",
labelNames: ["From"],
}));
metricsRegistry.registerMetric(new Counter({ metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.API_UNLOCK, name: BuzzerActivatedMetrics.API_UNLOCK,
help: "Door was unlocked with the API", help: "Door was unlocked with the API",

View File

@ -1,8 +1,10 @@
import { register, Registry, Summary } from "prom-client"; 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 { TwilioContext } from "../types/TwilioContext";
import { lambdaFastHttp } from "./LambdaUtils"; import { lambdaFastHttp } from "./LambdaUtils";
import { BuzzerActivatedMetrics } from "../metrics/BuzzerActivatedMetrics"; 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<InfoResponseClient | undefined> { export async function getConfig(context: TwilioContext, buzzer: string): Promise<InfoResponseClient | undefined> {
return await fetch(context.DOORMAN_URL + `/api/door/info?buzzer=${buzzer}`) 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; }) 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<LogCallResponse> {
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 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}`, `/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)); ).catch(err => console.log(err));
endTimer(); endTimer();

View File

@ -3,10 +3,10 @@ import { InfoResponseClient } from '../../../doorman-api/src/functions/api/door/
export function doorOpenTwiml(config: InfoResponseClient): VoiceResponse { export function doorOpenTwiml(config: InfoResponseClient): VoiceResponse {
const twiml = new Twilio.twiml.VoiceResponse(); const twiml = new Twilio.twiml.VoiceResponse();
// play audio // play audio
twiml.play('https://buzzer-2439-prod.twil.io/buzzing_up_boosted.mp3'); twiml.play('https://buzzer-2439-prod.twil.io/buzzing_up_boosted.mp3');
// press digit // press digit
twiml.play({ digits: config.pressKey }); // configured in doorman what button to click and passed into this function 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(); const twiml = new Twilio.twiml.VoiceResponse();
let dial = twiml.dial({ let dial = twiml.dial({
timeLimit: 20, timeLimit: 20,
timeout: 20 timeout: 20
}); });
@ -35,3 +35,36 @@ export function dialFallbackTwiml(config: InfoResponseClient): VoiceResponse {
// @ts-ignore // @ts-ignore
return twiml; 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;
}