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);
// this hardcoded door, keep open for timeout
if (door !== ONBOARDING_DOOR_NAME) {
await db.entities.lockStatus.deleteById(getLockStatusID(door));
}
} else {
const body = StatusResponseSchema.parse({
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 { 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) => {

View File

@ -3,7 +3,8 @@ import { waitForService, baseUrl, doorName, buzzerNumber, key, buzzerUrl } from
import { DoorStatus } from "../src/types/DoorStatus";
import { StatusResponse } from "../src/functions/api/door/status";
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
if (process.env.STAGE === 'staging') {
@ -85,3 +86,44 @@ describe("unlock path works", () => {
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);
});
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

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,
} 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,8 +39,23 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
}
}
// reject the call if this is not configured
// if this is not configured
if (!config || !config.door) {
// check if onboarding is enabled
const callLog = await tryLogCallerForOnboarding(context, event.From);
// onboarding is enabled, we got OTP to speak to user
if (callLog.otp) {
console.log(invokeId + " going to speak OTP to caller");
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();
@ -49,6 +64,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
callback(null, twiml);
return;
}
}
// let users know someone is currently buzzing, and allow unlock by discord user
let msg = `🔔 Someone is dialing right now @ Door "${config.door}" [Click to unlock](${context.DOORMAN_URL}/api/door/auth?door=${config.door}&key=`;

View File

@ -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",

View File

@ -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<InfoResponseClient | undefined> {
return await fetch(context.DOORMAN_URL + `/api/door/info?buzzer=${buzzer}`)
@ -12,7 +14,15 @@ 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<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 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}`,

View File

@ -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;
}