From e2da767c864eb9ea87b15730a32a2b66366544dc Mon Sep 17 00:00:00 2001 From: Martin Dimitrov Date: Tue, 10 Dec 2024 21:01:55 -0800 Subject: [PATCH] migrate api to typescript --- bun.lockb | Bin 408256 -> 409296 bytes packages/doorman-api/.gitignore | 3 + packages/doorman-api/.twilioserverlessrc | 1 + .../doorman-api/functions/api/door/auth.js | 99 --------------- .../doorman-api/functions/api/door/edit.js | 105 ---------------- .../doorman-api/functions/api/door/notify.js | 54 --------- .../doorman-api/functions/api/door/status.js | 64 ---------- packages/doorman-api/package.json | 9 +- .../src/functions/api/door/auth.ts | 112 +++++++++++++++++ .../src/functions/api/door/edit.ts | 113 ++++++++++++++++++ .../functions/api/door/info.ts} | 45 ++++--- .../src/functions/api/door/notify.ts | 54 +++++++++ .../src/functions/api/door/status.ts | 68 +++++++++++ packages/doorman-api/src/index.ts | 23 ++++ packages/doorman-api/src/types/AuthMethod.ts | 4 + packages/doorman-api/src/types/DoorConfig.ts | 14 +++ packages/doorman-api/src/types/DoorStatus.ts | 4 + packages/doorman-api/src/types/Lock.ts | 3 + .../doorman-api/src/types/TwilioContext.ts | 7 ++ .../utils/blockUserAgent.ts} | 6 +- .../ddb.private.js => src/utils/ddb.ts} | 54 +++++---- .../utils/discord.ts} | 34 ++++-- packages/doorman-homeassistant/src/index.ts | 2 +- 23 files changed, 501 insertions(+), 377 deletions(-) delete mode 100644 packages/doorman-api/functions/api/door/auth.js delete mode 100644 packages/doorman-api/functions/api/door/edit.js delete mode 100644 packages/doorman-api/functions/api/door/notify.js delete mode 100644 packages/doorman-api/functions/api/door/status.js create mode 100644 packages/doorman-api/src/functions/api/door/auth.ts create mode 100644 packages/doorman-api/src/functions/api/door/edit.ts rename packages/doorman-api/{functions/api/door/info.js => src/functions/api/door/info.ts} (55%) create mode 100644 packages/doorman-api/src/functions/api/door/notify.ts create mode 100644 packages/doorman-api/src/functions/api/door/status.ts create mode 100644 packages/doorman-api/src/index.ts create mode 100644 packages/doorman-api/src/types/AuthMethod.ts create mode 100644 packages/doorman-api/src/types/DoorConfig.ts create mode 100644 packages/doorman-api/src/types/DoorStatus.ts create mode 100644 packages/doorman-api/src/types/Lock.ts create mode 100644 packages/doorman-api/src/types/TwilioContext.ts rename packages/doorman-api/{functions/common/blockUserAgent.private.js => src/utils/blockUserAgent.ts} (73%) rename packages/doorman-api/{functions/common/ddb.private.js => src/utils/ddb.ts} (63%) rename packages/doorman-api/{functions/common/discord.private.js => src/utils/discord.ts} (59%) diff --git a/bun.lockb b/bun.lockb index d08672be1188b66728c6f8ffea108a7e1f791804..e3ed26c6c14fade4a1bb395f31156b4855ec034c 100755 GIT binary patch delta 6989 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 delta 6167 zcmeI0d2m(b8OBfUy$K;8qJTzH39=-pEKyMs3=kH93WXpdsGuSuU{NG&QK&bd>`PJ5 zgD8Tag4QaFcu^K{M^JESVpWXEAhT{+K>v8?fBbqI2*Q@&GCRNX!wPowHvYz)HZn>fKn~6^@&uN(w z$UeQhcV^_(eJhF!Ggp;ncFqjjW<(0+c$1QFd8NO z>ZUSSwhF73Me8=yX`oD(%LWi5tp(ei1_i+&GhOR|kqtJ}K{CwwJp{%l(B*Kmp#XKIbD+rV7S~OK?RF=_ zPOx}T1-RGnR##T1RT+^QDsttM(38=!5oXoUq1i~YlVNw6jWVkan}F6NM^mcZQ}E7_ zIDfHX!&A{+br-wcm205YxooUiO|&|fjdR^vXmu{T!|XJ)I+vBW?&)ZCE*l>=Oy@$K z%QRAr;S5|I$uuLe+PFHBO>*4~wCc;WLVBFP!Bt;2#q3P9>T9v5y1jTEE)*=AZkUN~ zrU#A9gvBW9;yEzkyWQYf=<^+Sk6As~MP_r%>cd)?%{6NP%dHf|D;m5PsM$5dyE#zL zRWr%Ldzj5L%Z6QMcE4F8SWmP0W@p2CnLS{pe_dCYJs9QrtCMr_-i8YZ)L1#BpFpgVS8jvWvsId{ zGb=z}hAXAko3%w>P9w^m4QB1oSE^y`1+(_(fo2=cI?#WxuOci6UNpRf>jP#lnRVp) z16*gp%VwR>Eoe|F`ifa+bbp?MQdAStBl#^pKmvOWrZHTK-yo*vzsax*x;}SxXl!=n zuIM^29U5EAeuq9CrksD%tQ)$zTE^Zo>yF-1L4shbS)uNmy$y?N5Iyj>4YvXLR4ns0 zN*y9QT)8KDx7kj!%emGg(c!YotQT76itHV;E70A&S>HA5t@#%MGr3u6*av-?*>0F7 zbtPU)IfuRHxU0~one8>Z8kTOh&#W)3qdV6BN=>*Q-U)V=)_=d@HNbo5tsZ5W*|q3T z&W>^aP(g0+W3wAU z<;3Zf)S$F7gYd(yt3hdYgYhrS4#SjfL-66aPQ^O{1a4TNSnS#ZGA6!|Z8w=EN`lDG9dY+Hg za(#70&KR~DQ0)VM!^2t>0B&POx||4Y=B{Hc(459nXd7U^)|~EiePm zGppwYXTmNvt8X?7rkUvwYGC$9v?eBNXf_+&FO{{`fsW#^jRi@u|yjU(pWj(+H5g;I7}(t#%u|iOo<1|=K{k=&^l6OZOtA-{W`Bkqf+@$ly6#ixj~v&{aZkfOH|uV;oc@C^4GRrdze z`EY~Ti|Blq&WC|!FQIF}WH*|zZ$4kwII&KqeB}{g+*&FEbVp{)O3^${dCJ$2%GuwjJi^3z6x5CuPoA^w$tjO#w z^enWpMX%hN=vI6^6}00=ndvo8U)#00N5d-K|MZ^M2dLdW#(~?>-C^3v#b$p&cY-aa zyv=L}x;adn_I9(K=%$Q7n{lk!F7)d#?Xht%P2?TTe-}`j><$OMi{1{?M)~{cSc+C< z?UNE$-i>~rCDdb>;JSOzbG_7en!N{`YIc{|UYJhXEn5GHhWmgzZnfl-%-%;YLu<(= zo9#z0Ld&L@m7z62W)w_?X?6#2?HP48-Ejxei_vOhhS*7nuk}KPHHd_1N1|C_^CQs? zmGwKx9(->&{z&xcu=BUkN0R!5pMD!%n=&d@-x$#9s>gACC^Jr5||b;}767@Co>& z#G&KS9Vy|upQDW`>nm+C@gu`yKSy^oX^70mYv6Nxbo8_-tH1Mo+{`R+q=x zBqa(WvHVC{VT8G3n;K}w^%Bcsv2|yq)L|KxM#7@3*t2QJ^{*R?C5PRzV_Avf>{!S5 z!qP}`^YF9XvD%4(XmW9^N@23z4EW}gnjEgpPpKWwNlk9Z+9%eeCU1 { - const isOpen = ddb.isLockOpen(lock); - - if (isOpen) { - const fingerprint = JSON.parse(lock.Item.fingerprint.S); - - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ - status: "CLOSED", - fingerprint, - }); - - await client.send(ddb.clearLockStatusCommand(lock)); - return; - } - - await client.send(ddb.setLockStatusCommand(door, timeout, fingerprint)) - .then(async (item) => { - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ msg: `Opened the door "${door}" for ${timeout}s` }); - }).catch((e) => { - console.log(e); - response - .setStatusCode(500) - .appendHeader('Content-Type', 'application/json') - .setBody({ err: e }); - }); - }); - - await client.destroy(); - return callback(null, response); -}; diff --git a/packages/doorman-api/functions/api/door/edit.js b/packages/doorman-api/functions/api/door/edit.js deleted file mode 100644 index 72268de..0000000 --- a/packages/doorman-api/functions/api/door/edit.js +++ /dev/null @@ -1,105 +0,0 @@ - -/** - * Edit API for doors - */ - -exports.handler = async function(context, event, callback) { - const response = new Twilio.Response(); - - const blockPath = Runtime.getFunctions()['common/blockUserAgent'].path; - const block = require(blockPath); - - if (block.shouldBlockRequest(event)) { - response.setStatusCode(200); - return callback(null, response); - } - - let door = event.door; - let approvalId = event.approvalId; - let newConfig = event.newConfig; - - const ddbPath = Runtime.getFunctions()['common/ddb'].path; - const discordPath = Runtime.getFunctions()['common/discord'].path; - const ddb = require(ddbPath); - const discord = require(discordPath); - const client = ddb.createDDBClient(context); - - // approve path - if (door && approvalId) { - const newConfig = await client.send(ddb.getDoorConfigUpdateCommand(door)); - - if (!newConfig || newConfig.Item.approvalId.S !== approvalId) { - response.setStatusCode(400); - return callback(null, response); - } - - await client.send(ddb.replaceDoorConfigWithUpdateItem(newConfig)); - - // send update to discord users - const updateMessage = `Configuration change \`${approvalId}\` was approved @ Door "${door}"`; - - const discordPromises = newConfig.Item.discordUsers.SS.map((user) => { - return discord.sendMessageToUser( - context, - user, - updateMessage, - ).catch(e => console.error(e)) - }); - - await Promise.all(discordPromises); - - response.setStatusCode(200); - return callback(null, response); - } - - if (!door || !newConfig) { - response.setStatusCode(400); - return callback(null, response); - } - - newConfig = JSON.parse(event.newConfig); - - const config = await client.send(ddb.getDoorConfigCommand(door)); - - if (!config.Item) { - response.setStatusCode(404); - return callback(null, response); - } - - // set to old PIN if it is missing - if (newConfig.pin === "") { - newConfig.pin = config.Item.pin.S; - } - - const input = ddb.putDoorUpdateConfigCommand(door, newConfig); - - const update = await client.send(input); - - newConfig.discordUser = undefined; - newConfig.fallbackNumber = undefined; - newConfig.status = undefined; - - const approvalUrl = `https://doorman.chromart.cc/api/door/edit?door=${door}&approvalId=${input.input.Item.approvalId.S}`; - console.log(approvalUrl); - - // send update to discord users - const approvalMessage = `Configuration change requested @ Door "${door}" [click to approve it](${approvalUrl})\`\`\`${JSON.stringify(newConfig, null, 4)}\`\`\``; - - const discordPromises = config.Item.discordUsers.SS.map((user) => { - return discord.sendMessageToUser( - context, - user, - approvalMessage, - ).catch(e => console.error(e)) - }); - - await Promise.all(discordPromises); - - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ msg: update }); - - await client.destroy(); - return callback(null, response); -}; diff --git a/packages/doorman-api/functions/api/door/notify.js b/packages/doorman-api/functions/api/door/notify.js deleted file mode 100644 index 85b8711..0000000 --- a/packages/doorman-api/functions/api/door/notify.js +++ /dev/null @@ -1,54 +0,0 @@ -function jsonMsgSuffix(jsonString) { - if (!jsonString) { - return ""; - } - try { - const fingerprint = JSON.parse(jsonString); - return `\`\`\`# Unlocked by\n${JSON.stringify(fingerprint, null, 4)}\`\`\``; - } catch (e) { - return `\`\`\`# Unlocked by\n# WARN: Unknown or corrupt raw fingerprint:\n ${jsonString}\`\`\``; - } -} - -exports.handler = async function(context, event, callback) { - const response = new Twilio.Response(); - const discordPath = Runtime.getFunctions()['common/discord'].path; - const discord = require(discordPath); - - let users = event.discordUser; - let msgs = event.msg; - let jsons = event.json; - let promises = []; - - try { - users = JSON.parse(users); - msgs = JSON.parse(msgs); - console.log("before parsing", jsons); - jsons = JSON.parse(jsons); - console.log("after parsing", jsons); - - promises = msgs.map((msg, i) => - discord.sendMessageToUser( - context, - users[i], - msg + jsonMsgSuffix(jsons[i]) - ).catch(e => console.error(e)) - ); - } catch (e) { - console.error(e); - response - .setStatusCode(500) - .appendHeader('Content-Type', 'application/json') - .setBody({ err: e, event }); - return callback(null, response); - } - - let timer = setTimeout(() => { - console.log("Ungraceful finish: running out of time"); - callback(null, response); - }, 9500); - - await Promise.all(promises); - clearTimeout(timer); - return callback(null, response); -}; diff --git a/packages/doorman-api/functions/api/door/status.js b/packages/doorman-api/functions/api/door/status.js deleted file mode 100644 index cf5927d..0000000 --- a/packages/doorman-api/functions/api/door/status.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Try to update door status - */ - -exports.handler = async function(context, event, callback) { - const response = new Twilio.Response(); - - const blockPath = Runtime.getFunctions()['common/blockUserAgent'].path; - const block = require(blockPath); - - if (block.shouldBlockRequest(event)) { - response.setStatusCode(200); - return callback(null, response); - } - - const door = event.door; - - if (!door) { - response.setStatusCode(400); - return callback(null, response); - } - - const ddbPath = Runtime.getFunctions()['common/ddb'].path; - const ddb = require(ddbPath); - - const client = ddb.createDDBClient(context); - - await client.send(ddb.getLockStatusCommand(door)) - .then(async (lock) => { - const isOpen = ddb.isLockOpen(lock); - - if (isOpen) { - const fingerprint = JSON.parse(lock.Item.fingerprint.S); - - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ - status: "OPEN", - fingerprint, - }); - - await client.send(ddb.clearLockStatusCommand(lock)); - return; - } - - response - .setStatusCode(200) - .appendHeader('Content-Type', 'application/json') - .setBody({ - status: "CLOSED", - }); - - }).catch((e) => { - console.log(e); - response - .setStatusCode(500) - .appendHeader('Content-Type', 'application/json') - .setBody({ err: e }); - }); - - await client.destroy(); - return callback(null, response); -}; diff --git a/packages/doorman-api/package.json b/packages/doorman-api/package.json index d65b5c1..329afb2 100644 --- a/packages/doorman-api/package.json +++ b/packages/doorman-api/package.json @@ -4,17 +4,22 @@ "private": true, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "start": "twilio-run --live --port 8080", + "start-twilio": "twilio-run --live --port 8080", + "watch-build": "bun run --watch src/index.ts", + "start": "concurrently \"bun run watch-build\" \"bun run start-twilio\"", + "build": "bun run src/index.ts", "deploy": "twilio-run deploy --load-system-env --env .env.example --service-name doorman --environment=prod --override-existing-project" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.609.0", + "@twilio-labs/serverless-runtime-types": "^4.0.1", "@twilio/runtime-handler": "1.3.0", "discord.js": "^14.16.3", "twilio": "^3.56" }, "devDependencies": { - "twilio-run": "^3.5.4" + "twilio-run": "^3.5.4", + "concurrently": "^9.1.0" }, "engines": { "node": "18" diff --git a/packages/doorman-api/src/functions/api/door/auth.ts b/packages/doorman-api/src/functions/api/door/auth.ts new file mode 100644 index 0000000..2ac80c8 --- /dev/null +++ b/packages/doorman-api/src/functions/api/door/auth.ts @@ -0,0 +1,112 @@ +/** + * Try to unlock the door with auth mode + */ + +import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types"; +import { TwilioContext } from "../../../types/TwilioContext"; +import { shouldBlockRequest, UserAgentHeader } from "../../../utils/blockUserAgent"; +import { clearLockStatusCommand, createDDBClient, ddbItemToJSON, getDoorConfigCommand, getLockStatusCommand, isLockOpen, setLockStatusCommand } from "../../../utils/ddb"; +import { DoorConfig } from "../../../types/DoorConfig"; +import { AuthMethod } from "../../../types/AuthMethod"; +import { Lock } from "../../../types/Lock"; +import { DoorStatus } from "../../../types/DoorStatus"; + +export interface AuthRequest extends ServerlessEventObject<{}, UserAgentHeader> { + door?: string; + key?: string; + ip: string; + timeout?: string; +} + +export const handler: ServerlessFunctionSignature = async function(context, event, callback) { + const response = new Twilio.Response(); + + if (shouldBlockRequest(event)) { + response.setStatusCode(200); + return callback(null, response); + } + + let door = event.door; + let pin = event.key; + + if (!door || !pin) { + response.setStatusCode(400); + return callback(null, response); + } + + const client = createDDBClient(context); + + const ddbConfig = await client.send(getDoorConfigCommand(door)); + const config: DoorConfig = ddbItemToJSON(ddbConfig); + + if (!config) { + response.setStatusCode(404); + return callback(null, response); + } + + let correctPin = config.pin; + let discordUsers = config.discordUsers; + + let method: AuthMethod | undefined; + + if (correctPin === pin) { + method = AuthMethod.PIN; + } + + if (discordUsers.includes(pin)) { + method = AuthMethod.DISCORD; + } + + if (!method) { + response.setStatusCode(401); + return callback(null, response); + } + + const fingerprint = { + method, + userAgent: event.request.headers['user-agent'], + ip: event.ip, + }; + + // take timeout from the query string + const timeout = event.timeout ? parseInt(event.timeout) : config.timeout; + + // check lock status if locked, then unlock. If unlocked then lock + await client.send(getLockStatusCommand(door)) + .then(async (lockDdb) => { + const isOpen = isLockOpen(lockDdb); + + if (isOpen) { + const lock: Lock = ddbItemToJSON(lockDdb); + const fingerprint = JSON.parse(lock.fingerprint); + + response + .setStatusCode(200) + .appendHeader('Content-Type', 'application/json') + .setBody({ + status: DoorStatus.CLOSED, + fingerprint, + }); + + await client.send(clearLockStatusCommand(lockDdb)); + return; + } + + await client.send(setLockStatusCommand(door, timeout, fingerprint)) + .then(async (item) => { + response + .setStatusCode(200) + .appendHeader('Content-Type', 'application/json') + .setBody({ msg: `Opened the door "${door}" for ${timeout}s` }); + }).catch((e) => { + console.log(e); + response + .setStatusCode(500) + .appendHeader('Content-Type', 'application/json') + .setBody({ err: e }); + }); + }); + + await client.destroy(); + return callback(null, response); +}; diff --git a/packages/doorman-api/src/functions/api/door/edit.ts b/packages/doorman-api/src/functions/api/door/edit.ts new file mode 100644 index 0000000..c261dce --- /dev/null +++ b/packages/doorman-api/src/functions/api/door/edit.ts @@ -0,0 +1,113 @@ + +/** + * Edit API for doors + */ + +import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types"; +import { TwilioContext } from "../../../types/TwilioContext"; +import { shouldBlockRequest, UserAgentHeader } from "../../../utils/blockUserAgent"; +import { createDDBClient, ddbItemToJSON, getDoorConfigCommand, getDoorConfigUpdateCommand, putDoorUpdateConfigCommand, replaceDoorConfigWithUpdateItem } from "../../../utils/ddb"; +import { DoorConfig, EditDoorConfig } from "../../../types/DoorConfig"; +import { sendMessageToUser } from "../../../utils/discord"; + +export interface EditRequest extends ServerlessEventObject<{}, UserAgentHeader> { + door?: string; + approvalId?: string; + newConfig?: string; +} + +export const handler: ServerlessFunctionSignature = async function(context, event, callback) { + const response = new Twilio.Response(); + + if (shouldBlockRequest(event)) { + response.setStatusCode(200); + return callback(null, response); + } + + let door = event.door; + let approvalId = event.approvalId; + let newConfigString = event.newConfig; + + const client = createDDBClient(context); + + // approve path + if (door && approvalId) { + const newConfigDdb = await client.send(getDoorConfigUpdateCommand(door)); + const newConfig = ddbItemToJSON(newConfigDdb); + + if (!newConfig || newConfig.approvalId !== approvalId) { + response.setStatusCode(400); + return callback(null, response); + } + + await client.send(replaceDoorConfigWithUpdateItem(newConfigDdb as any)); + + // send update to discord users + const updateMessage = `Configuration change \`${approvalId}\` was approved @ Door "${door}"`; + + const discordPromises = newConfig.discordUsers.map((user) => { + return sendMessageToUser( + context, + user, + updateMessage, + ).catch(e => console.error(e)) + }); + + await Promise.all(discordPromises); + + response.setStatusCode(200); + return callback(null, response); + } + + if (!door || !newConfigString) { + response.setStatusCode(400); + return callback(null, response); + } + + const newConfig: EditDoorConfig = JSON.parse(newConfigString); + + const configDdb = await client.send(getDoorConfigCommand(door)); + const config = ddbItemToJSON(configDdb); + + if (!config) { + response.setStatusCode(404); + return callback(null, response); + } + + // set to old PIN if it is missing + if (newConfig.pin === "") { + newConfig.pin = config.pin; + } + + const input = putDoorUpdateConfigCommand(door, newConfig); + + const update = await client.send(input); + + // newConfig.discordUsers = undefined; + // newConfig.fallbackNumbers = undefined; + // newConfig.status = undefined; + + const approvalUrl = `https://doorman.chromart.cc/api/door/edit?door=${door}&approvalId=${input?.input?.Item?.approvalId.S as string}`; + console.log(approvalUrl); + + // send update to discord users + const approvalMessage = `Configuration change requested @ Door "${door}" [click to approve it](${approvalUrl})\`\`\`${JSON.stringify(newConfig, null, 4)}\`\`\``; + + const discordPromises = config.discordUsers.map((user) => { + return sendMessageToUser( + context, + user, + approvalMessage, + ).catch(e => console.error(e)) + }); + + await Promise.all(discordPromises); + + response + .setStatusCode(200) + .appendHeader('Content-Type', 'application/json') + .setBody({ msg: update }); + + await client.destroy(); + return callback(null, response); +}; diff --git a/packages/doorman-api/functions/api/door/info.js b/packages/doorman-api/src/functions/api/door/info.ts similarity index 55% rename from packages/doorman-api/functions/api/door/info.js rename to packages/doorman-api/src/functions/api/door/info.ts index d81a5b0..92952c2 100644 --- a/packages/doorman-api/functions/api/door/info.js +++ b/packages/doorman-api/src/functions/api/door/info.ts @@ -2,7 +2,23 @@ * Try to get door info */ -exports.handler = async function(context, event, callback) { +import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types"; +import { TwilioContext } from "../../../types/TwilioContext"; +import { createDDBClient, ddbItemToJSON, getDoorAliasCommand, getDoorConfigCommand, getLockStatusCommand, isLockOpen } from "../../../utils/ddb"; +import { DoorStatus } from "../../../types/DoorStatus"; +import { DoorConfig } from "../../../types/DoorConfig"; + +export interface InfoRequest extends ServerlessEventObject { + door?: string; + buzzer?: string; + + // TODO: change these to be multiple + discordUser: string; + msg: string; + json: string; +} + +export const handler: ServerlessFunctionSignature = async function(context, event, callback) { const response = new Twilio.Response(); let door = event.door; @@ -13,12 +29,10 @@ exports.handler = async function(context, event, callback) { return callback(null, response); } - const ddbPath = Runtime.getFunctions()['common/ddb'].path; - const ddb = require(ddbPath); - const client = ddb.createDDBClient(context); + const client = createDDBClient(context); if (buzzer) { - door = await client.send(ddb.getDoorAliasCommand(buzzer)) + door = await client.send(getDoorAliasCommand(buzzer)) .then(async (alias) => { if (!alias.Item) { response @@ -32,7 +46,7 @@ exports.handler = async function(context, event, callback) { } if (door) { - const config = await client.send(ddb.getDoorConfigCommand(door)); + const config = await client.send(getDoorConfigCommand(door)); if (!config.Item) { response @@ -53,9 +67,10 @@ exports.handler = async function(context, event, callback) { discordUsers: config.Item?.discordUsers?.SS || [], }); } else { - await client.send(ddb.getLockStatusCommand(door)) + await client.send(getLockStatusCommand(door)) .then(async (lock) => { - const status = ddb.isLockOpen(lock) ? "OPEN": "CLOSED"; + const status = isLockOpen(lock) ? DoorStatus.OPEN: DoorStatus.CLOSED; + const doorConfig: DoorConfig = ddbItemToJSON(lock); // respond to UI response @@ -63,14 +78,14 @@ exports.handler = async function(context, event, callback) { .appendHeader('Content-Type', 'application/json') .setBody({ id: door, - timeout: config.Item.timeout.N, - buzzer: config.Item.buzzer.S, + timeout: doorConfig.timeout, + buzzer: doorConfig.buzzer, status, - buzzerCode: config.Item.buzzerCode.S, - fallbackNumbers: config.Item.fallbackNumbers.SS, - pressKey: config.Item.pressKey.S, - discordUsers: config.Item?.discordUsers?.SS || [], - greeting: config.Item?.greeting?.S || "", + buzzerCode: doorConfig.buzzerCode, + fallbackNumbers: doorConfig.fallbackNumbers, + pressKey: doorConfig.pressKey, + discordUsers: doorConfig.discordUsers || [], + greeting: doorConfig.greeting || "", }); }).catch((e) => { diff --git a/packages/doorman-api/src/functions/api/door/notify.ts b/packages/doorman-api/src/functions/api/door/notify.ts new file mode 100644 index 0000000..67eb7fb --- /dev/null +++ b/packages/doorman-api/src/functions/api/door/notify.ts @@ -0,0 +1,54 @@ +import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types"; +import { TwilioContext } from "../../../types/TwilioContext"; +import { jsonMsgSuffix, sendMessageToUser } from "../../../utils/discord"; + + +export interface NotifyRequest extends ServerlessEventObject { + door: string; + + // TODO: change these to be multiple + discordUser: string; + msg: string; + json: string; +} + +export const handler: ServerlessFunctionSignature = async function(context, event, callback) { + const response = new Twilio.Response(); + + let users: string[]; + let msgs: string[]; + let jsons: string[]; + let promises = []; + + try { + users = JSON.parse(event.discordUser); + msgs = JSON.parse(event.msg); + console.log("before parsing", event.json); + jsons = JSON.parse(event.json); + console.log("after parsing", event.json); + + promises = msgs.map((msg, i) => + sendMessageToUser( + context, + users[i], + msg + jsonMsgSuffix(jsons[i]) + ).catch((e: Error) => console.error(e)) + ); + } catch (e) { + console.error(e); + response + .setStatusCode(500) + .appendHeader('Content-Type', 'application/json') + .setBody({ err: e, event }); + return callback(null, response); + } + + let timer = setTimeout(() => { + console.log("Ungraceful finish: running out of time"); + callback(null, response); + }, 9500); + + await Promise.all(promises); + clearTimeout(timer); + 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 new file mode 100644 index 0000000..4a4c401 --- /dev/null +++ b/packages/doorman-api/src/functions/api/door/status.ts @@ -0,0 +1,68 @@ +/** + * Try to update door status + */ + +import { ServerlessFunctionSignature, ServerlessEventObject } from "@twilio-labs/serverless-runtime-types/types"; +import { TwilioContext } from "../../../types/TwilioContext"; +import { shouldBlockRequest } from "../../../utils/blockUserAgent"; +import { clearLockStatusCommand, createDDBClient, getLockStatusCommand, isLockOpen } from "../../../utils/ddb"; +import { DoorStatus } from "../../../types/DoorStatus"; + +export interface StatusRequest extends ServerlessEventObject { + door: string; +} + +export const handler: ServerlessFunctionSignature = async function(context, event, callback) { + const response = new Twilio.Response(); + + if (shouldBlockRequest(event)) { + response.setStatusCode(200); + return callback(null, response); + } + + const door = event.door; + + if (!door) { + response.setStatusCode(400); + return callback(null, response); + } + + const client = createDDBClient(context); + + await client.send(getLockStatusCommand(door)) + .then(async (lock) => { + const isOpen = isLockOpen(lock); + + if (isOpen) { + const fingerprint = JSON.parse(lock?.Item?.fingerprint.S as string); + + response + .setStatusCode(200) + .appendHeader('Content-Type', 'application/json') + .setBody({ + status: DoorStatus.OPEN, + fingerprint, + }); + + await client.send(clearLockStatusCommand(lock)); + return; + } + + response + .setStatusCode(200) + .appendHeader('Content-Type', 'application/json') + .setBody({ + status: DoorStatus.CLOSED, + }); + + }).catch((e) => { + console.log(e); + response + .setStatusCode(500) + .appendHeader('Content-Type', 'application/json') + .setBody({ err: e }); + }); + + await client.destroy(); + return callback(null, response); +}; diff --git a/packages/doorman-api/src/index.ts b/packages/doorman-api/src/index.ts new file mode 100644 index 0000000..2b35544 --- /dev/null +++ b/packages/doorman-api/src/index.ts @@ -0,0 +1,23 @@ +import { readdirSync } from "node:fs"; +import path from "path"; + +const paths = [ + './src/functions/api/door' +]; + +const functionFiles = paths.map(path => readdirSync(path).map(file => path + "/" + file)).flat(); + +// for hot reload to work, we import all the files we want to build +const imports = functionFiles.forEach(file => require('./' + path.relative('src', file))); + +console.log("functions to build:", functionFiles); + +console.log("Building functions..."); +await Bun.build({ + entrypoints: functionFiles, + outdir: './build/functions', + packages: 'external', + target: 'node', + root: './src/functions', + format: 'cjs', +}); diff --git a/packages/doorman-api/src/types/AuthMethod.ts b/packages/doorman-api/src/types/AuthMethod.ts new file mode 100644 index 0000000..f897065 --- /dev/null +++ b/packages/doorman-api/src/types/AuthMethod.ts @@ -0,0 +1,4 @@ +export enum AuthMethod { + PIN = "PIN", + DISCORD = "DISCORD", +} diff --git a/packages/doorman-api/src/types/DoorConfig.ts b/packages/doorman-api/src/types/DoorConfig.ts new file mode 100644 index 0000000..297a99e --- /dev/null +++ b/packages/doorman-api/src/types/DoorConfig.ts @@ -0,0 +1,14 @@ +export interface DoorConfig { + buzzer: string; + buzzerCode: string; + discordUsers: string[]; + fallbackNumbers: string[]; + pin: string; + pressKey: string; + timeout: number; + greeting: string; +} + +export interface EditDoorConfig extends DoorConfig { + approvalId: string; +} diff --git a/packages/doorman-api/src/types/DoorStatus.ts b/packages/doorman-api/src/types/DoorStatus.ts new file mode 100644 index 0000000..1c5b4eb --- /dev/null +++ b/packages/doorman-api/src/types/DoorStatus.ts @@ -0,0 +1,4 @@ +export enum DoorStatus { + OPEN = "OPEN", + CLOSED = "CLOSED", +} \ No newline at end of file diff --git a/packages/doorman-api/src/types/Lock.ts b/packages/doorman-api/src/types/Lock.ts new file mode 100644 index 0000000..d0608f2 --- /dev/null +++ b/packages/doorman-api/src/types/Lock.ts @@ -0,0 +1,3 @@ +export interface Lock { + fingerprint: any; +} diff --git a/packages/doorman-api/src/types/TwilioContext.ts b/packages/doorman-api/src/types/TwilioContext.ts new file mode 100644 index 0000000..fa427af --- /dev/null +++ b/packages/doorman-api/src/types/TwilioContext.ts @@ -0,0 +1,7 @@ +import { EnvironmentVariables } from "@twilio-labs/serverless-runtime-types/types"; + +export interface TwilioContext extends EnvironmentVariables { + AWS_ACCESS_KEY: string; + AWS_SECRET_ACCESS_KEY: string; + DISCORD_BOT_TOKEN: string; +} diff --git a/packages/doorman-api/functions/common/blockUserAgent.private.js b/packages/doorman-api/src/utils/blockUserAgent.ts similarity index 73% rename from packages/doorman-api/functions/common/blockUserAgent.private.js rename to packages/doorman-api/src/utils/blockUserAgent.ts index c073d4a..c681201 100644 --- a/packages/doorman-api/functions/common/blockUserAgent.private.js +++ b/packages/doorman-api/src/utils/blockUserAgent.ts @@ -1,8 +1,12 @@ +import { ServerlessEventObject } from "@twilio-labs/serverless-runtime-types/types"; + +export type UserAgentHeader = { "user-agent"?: string }; + /** * Helper method to BLOCK discordbot from scraping API links * This is a bit of a hack until we process event links from UI instead of raw API */ -exports.shouldBlockRequest = (event) => { +export function shouldBlockRequest(event: ServerlessEventObject<{}, UserAgentHeader>): boolean { let headers = event?.request?.headers; let userAgentString = ""; diff --git a/packages/doorman-api/functions/common/ddb.private.js b/packages/doorman-api/src/utils/ddb.ts similarity index 63% rename from packages/doorman-api/functions/common/ddb.private.js rename to packages/doorman-api/src/utils/ddb.ts index 03ebb44..3637c72 100644 --- a/packages/doorman-api/functions/common/ddb.private.js +++ b/packages/doorman-api/src/utils/ddb.ts @@ -1,7 +1,9 @@ -const { randomUUID } = require("crypto"); -const { DynamoDBClient, GetItemCommand, DeleteItemCommand, PutItemCommand, UpdateItemCommand } = require("@aws-sdk/client-dynamodb"); +import { randomUUID } from "crypto"; +import { DynamoDBClient, GetItemCommand, DeleteItemCommand, PutItemCommand, UpdateItemCommand, GetItemOutput } from "@aws-sdk/client-dynamodb"; +import { TwilioContext } from "../types/TwilioContext"; +import { DoorConfig } from "../types/DoorConfig"; -exports.createDDBClient = (context) => { +export const createDDBClient = (context: TwilioContext) => { return new DynamoDBClient({ region: "us-east-1" , credentials: { @@ -11,7 +13,7 @@ exports.createDDBClient = (context) => { }); }; -exports.getLockStatusCommand = (door) => { +export const getLockStatusCommand = (door: string) => { return new GetItemCommand({ TableName: "doorman", Key: { @@ -25,7 +27,7 @@ exports.getLockStatusCommand = (door) => { }); }; -exports.getDoorAliasCommand = (buzzerNumber) => { +export const getDoorAliasCommand = (buzzerNumber: string) => { return new GetItemCommand({ TableName: "doorman", Key: { @@ -39,7 +41,7 @@ exports.getDoorAliasCommand = (buzzerNumber) => { }); }; -exports.getDoorConfigCommand = (door) => { +export const getDoorConfigCommand = (door: string) => { return new GetItemCommand({ TableName: "doorman", Key: { @@ -53,18 +55,19 @@ exports.getDoorConfigCommand = (door) => { }); }; -exports.isLockOpen = (lock) => { +export const isLockOpen = (lock: GetItemOutput) => { // ttl is a UTC ms time for how long it is unlocked const ttl = lock.Item?.TTL?.N || 0; - return ttl > Date.now(); + + return parseInt("" + ttl) > Date.now(); }; -exports.clearLockStatusCommand = (lock) => { +export const clearLockStatusCommand = (lock: GetItemOutput) => { return new DeleteItemCommand({ TableName: "doorman", Key: { "PK": { - S: lock.Item.PK.S, + S: lock?.Item?.PK.S as string, }, "SK": { S: "lock", @@ -73,7 +76,7 @@ exports.clearLockStatusCommand = (lock) => { }); }; -exports.setLockStatusCommand = (door, timeoutSeconds, fingerprintObj) => { +export const setLockStatusCommand = (door: string, timeoutSeconds: number, fingerprintObj: any) => { return new PutItemCommand({ TableName: "doorman", Item: { @@ -93,7 +96,7 @@ exports.setLockStatusCommand = (door, timeoutSeconds, fingerprintObj) => { }); }; -exports.putDoorUpdateConfigCommand = (door, config) => { +export const putDoorUpdateConfigCommand = (door: string, config: DoorConfig) => { return new PutItemCommand({ TableName: "doorman", Item: { @@ -134,21 +137,22 @@ exports.putDoorUpdateConfigCommand = (door, config) => { }); }; -exports.getDoorConfigCommand = (door) => { - return new GetItemCommand({ - TableName: "doorman", - Key: { - "PK": { - S: `door-${door}`, - }, - "SK": { - S: "config", - }, - }, +export function ddbItemToJSON>(ddbItem: GetItemOutput): T { + let obj: any = {}; + + if (!ddbItem.Item) { + return obj; + } + + const Item = ddbItem.Item; + + Object.keys(Item).forEach(key => { + obj[key] = Object.values(Item[key])[0]; }); + return obj; }; -exports.getDoorConfigUpdateCommand = (door) => { +export const getDoorConfigUpdateCommand = (door: string) => { return new GetItemCommand({ TableName: "doorman", Key: { @@ -162,7 +166,7 @@ exports.getDoorConfigUpdateCommand = (door) => { }); }; -exports.replaceDoorConfigWithUpdateItem = (newConfigItem) => { +export const replaceDoorConfigWithUpdateItem = (newConfigItem: GetItemOutput & { Item: { approvalId?: { S: string } }}) => { const newItem = { ...newConfigItem.Item, SK: { S: "config" }, diff --git a/packages/doorman-api/functions/common/discord.private.js b/packages/doorman-api/src/utils/discord.ts similarity index 59% rename from packages/doorman-api/functions/common/discord.private.js rename to packages/doorman-api/src/utils/discord.ts index d785b83..6829ab8 100644 --- a/packages/doorman-api/functions/common/discord.private.js +++ b/packages/doorman-api/src/utils/discord.ts @@ -2,13 +2,13 @@ // https://gitea.chromart.cc/martin/refbounty/src/commit/1f40d7870a530fe7e7acbc3b901024092c9fb90a/packages/server/src/connectors/DiscordConnector.ts // https://gitea.chromart.cc/martin/refbounty/src/commit/1f40d7870a530fe7e7acbc3b901024092c9fb90a/packages/server/src/utils/DiscordUtils.ts -const { Client, GatewayIntentBits } = require("discord.js"); +import { Client, GatewayIntentBits, User } from "discord.js"; +import { TwilioContext } from "../types/TwilioContext"; -// TODO: cache these at top level in handler code -let conn; -let userCache = {}; +let conn: Client; +let userCache: Record = {}; -exports.getDiscordClient = async (context) => { +export const getDiscordClient = async (context: TwilioContext) => { if (!conn) { console.log("[DiscordClientCache] cache miss for discord"); const client = new Client({ @@ -28,13 +28,13 @@ exports.getDiscordClient = async (context) => { }; -exports.sendMessageToUser = async ( - context, - userId, - msg, +export const sendMessageToUser = async ( + context: TwilioContext, + userId: string, + msg: string, ) => { try { - const client = await exports.getDiscordClient(context); + const client = await getDiscordClient(context); if (userCache[userId] === undefined) { console.log("[UserCache] cache miss for", userId); userCache[userId] = await client.users.fetch(userId); @@ -47,4 +47,16 @@ exports.sendMessageToUser = async ( console.log(e); console.log(`Failed to send msg to ${userId}`); } -} +}; + +export const jsonMsgSuffix = (jsonString: string = "") => { + if (!jsonString) { + return ""; + } + try { + const fingerprint = JSON.parse(jsonString); + return `\`\`\`# Unlocked by\n${JSON.stringify(fingerprint, null, 4)}\`\`\``; + } catch (e) { + return `\`\`\`# Unlocked by\n# WARN: Unknown or corrupt raw fingerprint:\n ${jsonString}\`\`\``; + } +}; diff --git a/packages/doorman-homeassistant/src/index.ts b/packages/doorman-homeassistant/src/index.ts index 980cda9..be987a0 100644 --- a/packages/doorman-homeassistant/src/index.ts +++ b/packages/doorman-homeassistant/src/index.ts @@ -2,7 +2,7 @@ import { serve } from "bun"; import { Hono } from "hono"; import { prettyJSON } from "hono/pretty-json"; -import * as ddb from "../../doorman-api/functions/common/ddb.private"; +import * as ddb from "../../doorman-api/deprecated-functions/common/ddb.private"; const app = new Hono();