From f939681b3d451a180feaf80519157d69077f1921 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 5 May 2026 14:51:29 -0400 Subject: [PATCH 1/9] chore: add webhook deps and rooms prebuild script Adds @netlify/functions, @slack/bolt, slackify-html, dotenv, and @types/slackify-html to support the consolidated webhook functions. Adds scripts/build-rooms.ts to generate data/rooms.json from Airtable during prebuild, and chains it into the existing prebuild pipeline. --- data/.gitignore | 2 + package.json | 9 +- pnpm-lock.yaml | 487 ++++++++++++++++++++++++++++++++++++++++- scripts/build-rooms.ts | 33 +++ 4 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 data/.gitignore create mode 100644 scripts/build-rooms.ts diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/package.json b/package.json index d3608c1d..ba32d4ed 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "format": "npx prettier --write --ignore-unknown --list-different \"**/*\"", "lint": "eslint", "build-member-files": "tsx scripts/loadMemberFiles.js", + "build-rooms": "tsx scripts/build-rooms.ts", "local-dev": "cross-env NODE_ENV=development pnpm netlify dev", "build": "next build", "start": "next start", - "prebuild": "pnpm build-member-files", + "prebuild": "pnpm build-member-files && pnpm build-rooms", + "typecheck": "tsc --noEmit", "watch": "npm-watch", "dev": "pnpm build-member-files && concurrently \"pnpm watch\" \"pnpm local-dev\"" }, @@ -28,8 +30,10 @@ "@imgix/js-core": "^3.8.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", + "@netlify/functions": "^5.1.5", "@next/mdx": "15.5.5", "@sindresorhus/slugify": "^2.2.1", + "@slack/bolt": "^4.6.0", "@types/mdx": "^2.0.13", "airtable": "^0.12.2", "bootstrap": "^4.6.2", @@ -61,6 +65,7 @@ "remark-rehype": "^10.1.0", "require-dir": "^1.2.0", "sanitize-html": "^2.17.0", + "slackify-html": "^1.0.1", "unified": "^10.1.2" }, "devDependencies": { @@ -74,9 +79,11 @@ "@types/react-dom": "^19.2.3", "@types/require-dir": "^1.0.4", "@types/sanitize-html": "^2.16.0", + "@types/slackify-html": "^1.0.8", "all-contributors-cli": "^6.26.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "dotenv": "^16.0.2", "esbuild": "^0.25.12", "eslint": "^9.39.2", "eslint-config-next": "^15.5.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03c3872c..242c72f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,18 @@ importers: '@mdx-js/react': specifier: ^3.1.1 version: 3.1.1(@types/react@19.2.7)(react@19.2.3) + '@netlify/functions': + specifier: ^5.1.5 + version: 5.2.0 '@next/mdx': specifier: 15.5.5 version: 15.5.5(@mdx-js/loader@3.1.1(webpack@5.102.0(esbuild@0.25.12)))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)) '@sindresorhus/slugify': specifier: ^2.2.1 version: 2.2.1 + '@slack/bolt': + specifier: ^4.6.0 + version: 4.7.2 '@types/mdx': specifier: ^2.0.13 version: 2.0.13 @@ -125,6 +131,9 @@ importers: sanitize-html: specifier: ^2.17.0 version: 2.17.0 + slackify-html: + specifier: ^1.0.1 + version: 1.0.1 unified: specifier: ^10.1.2 version: 10.1.2 @@ -159,6 +168,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.0 version: 2.16.0 + '@types/slackify-html': + specifier: ^1.0.8 + version: 1.0.8 all-contributors-cli: specifier: ^6.26.1 version: 6.26.1 @@ -168,6 +180,9 @@ importers: cross-env: specifier: ^10.1.0 version: 10.1.0 + dotenv: + specifier: ^16.0.2 + version: 16.6.1 esbuild: specifier: ^0.25.12 version: 0.25.12 @@ -1131,6 +1146,10 @@ packages: resolution: {integrity: sha512-28hNylsGcdxq6fngFXIShxVhamzw2K5twEx7pQaYFDjj52OLHjd0L+vPZ9HYUaeOoHuJgq8ir3RoVgITwcY+3g==} engines: {node: '>=18.14.0'} + '@netlify/functions@5.2.0': + resolution: {integrity: sha512-Pj93qeQd1tkQ5xm9gWJZmBf/1riLYqYHc0OzFukrJomrj82Ott53Rr/Q88H1ms5cF+P5QXRKWmA2JSxSybKfjA==} + engines: {node: '>=18.0.0'} + '@netlify/git-utils@6.0.2': resolution: {integrity: sha512-ASp8T6ZAxL5OE0xvTTn5+tIBua5F8ruLH7oYtI/m2W/8rYb9V3qvNeenf9SnKlGj1xv6mPv8l7Tc93kmBLLofw==} engines: {node: '>=18.14.0'} @@ -1261,6 +1280,10 @@ packages: resolution: {integrity: sha512-XOWlZ2wPpdRKkAOcQbjIf/Qz7L4RjcSVINVNQ9p3F6U8V6KSEOsB3fPrc6Ly8EOeJioHUepRPuzHzJE/7V5EsA==} engines: {node: ^18.14.0 || >=20} + '@netlify/types@2.6.0': + resolution: {integrity: sha512-yD20EizHJDQxajJ66Vo8RTwLwR2jMNVxufPG8MHd2AScX8jW4z0VPnnJHArq2GYPFTFZRHmiAhDrXr5m8zof6w==} + engines: {node: ^18.14.0 || >=20} + '@netlify/zip-it-and-ship-it@14.1.14': resolution: {integrity: sha512-33w50VcYLZ7RpUCFvl+n8JoLRGSVKerbH6cXtVjzA7un9JSkJWZQVS3nDmWYbq6OR0VnS1LGn7r+/ll6pSOvCg==} engines: {node: '>=18.14.0'} @@ -1626,6 +1649,32 @@ packages: resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} engines: {node: '>=12'} + '@slack/bolt@4.7.2': + resolution: {integrity: sha512-ALHtaS2iaP2WAWgX08yXsoCxEDitC6AqZs26ot6smXJQzBFMM4slVP+w3blLwzUV551xZ/+9RlBmWHsZDJJ5HA==} + engines: {node: '>=18', npm: '>=8.6.0'} + peerDependencies: + '@types/express': ^5.0.0 + + '@slack/logger@4.0.1': + resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/oauth@3.0.5': + resolution: {integrity: sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==} + engines: {node: '>=18', npm: '>=8.6.0'} + + '@slack/socket-mode@2.0.7': + resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.21.0': + resolution: {integrity: sha512-ZLMsKnD5KLRPmhFEoGoBQUD5Pc2bH3xFc5ygHlioEc0WmLGyZGoGCtMff4rpejrFnptrhfxcKpWxW4r3g39R0A==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.15.2': + resolution: {integrity: sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -1690,6 +1739,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/leaflet.markercluster@1.5.6': resolution: {integrity: sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==} @@ -1737,12 +1789,18 @@ packages: '@types/require-dir@1.0.4': resolution: {integrity: sha512-wZlfKxz4F97pMVAa9ztjm0TnaK4rH0COO9EE7AkMbvuIcXkrlFZQm2naZpITFN+PBqJ8vgxPV2+M6TzxWUQUSg==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} '@types/sanitize-html@2.16.0': resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/slackify-html@1.0.8': + resolution: {integrity: sha512-wA1YZkD/MyxXfLphMiUPOqPQOVle31z6sQFa0lQFTZUT3QbgmWKOenh4oetheasCt+Kmi/oh5VrdAEM/EgaXSQ==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -1755,6 +1813,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -2052,6 +2113,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -2263,6 +2328,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2281,6 +2349,9 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2342,6 +2413,10 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2584,6 +2659,10 @@ packages: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2643,6 +2722,10 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -2653,6 +2736,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} @@ -2842,6 +2929,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -3288,6 +3379,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -3311,6 +3405,10 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -3452,6 +3550,10 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@8.2.2: resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} engines: {node: '>=14'} @@ -3494,6 +3596,15 @@ packages: debug: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3506,6 +3617,10 @@ packages: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -3522,6 +3637,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -3786,12 +3905,19 @@ packages: html-element-attributes@2.3.0: resolution: {integrity: sha512-RJv2v3BBaYSc0ODHwT0sqWI+2lFs6DATBvCRnW20BDmULxoAWvfT6r28uL8LcW1a9/eqUl+1DccUOJzw00qVXQ==} + html-entities@1.4.0: + resolution: {integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==} + html-void-elements@2.0.1: resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser@1.7.7: + resolution: {integrity: sha512-zpK66ifkT0fauyFh2Mulrq4AqGTucxGtOhZ8OjkbSfcCpkqQEI8qRkY0tSQSJNAQ4HUZkgWaU4fK4EH6SVH9PQ==} + engines: {node: '>=0.1.33'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3803,6 +3929,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-middleware@2.0.9: resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} engines: {node: '>=12.0.0'} @@ -3997,6 +4127,9 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-error-instance@2.0.0: resolution: {integrity: sha512-5RuM+oFY0P5MRa1nXJo6IcTx9m2VyXYhRtb4h0olsi2GHci4bqZ6akHk+GmCYvDrAR9yInbiYdr2pnoqiOMw/Q==} engines: {node: '>=16.17.0'} @@ -4094,6 +4227,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4539,12 +4675,20 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-options@3.0.4: resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} engines: {node: '>=10'} @@ -4729,6 +4873,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4843,6 +4991,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5082,6 +5234,10 @@ packages: resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} engines: {node: '>=18'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -5110,14 +5266,26 @@ packages: resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} engines: {node: '>=18'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + p-reduce@3.0.0: resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==} engines: {node: '>=12'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-retry@6.2.1: resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} engines: {node: '>=16.17'} + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-timeout@5.1.0: resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==} engines: {node: '>=12'} @@ -5212,6 +5380,9 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@6.0.0: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} @@ -5398,6 +5569,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + ps-list@8.1.1: resolution: {integrity: sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5427,6 +5602,10 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5680,6 +5859,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -5781,10 +5964,18 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -5853,6 +6044,9 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + slackify-html@1.0.1: + resolution: {integrity: sha512-9e5Wo8Z2QSORedN6vqImnjIUwaHI8mpjeQQfXBcIcvIewoJ9SGB56MN2FVIPt6ACn+g4gLsQZHeGXwe5VQMnzA==} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -5935,6 +6129,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -6252,6 +6450,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -6273,6 +6475,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -7632,6 +7838,10 @@ snapshots: - rollup - supports-color + '@netlify/functions@5.2.0': + dependencies: + '@netlify/types': 2.6.0 + '@netlify/git-utils@6.0.2': dependencies: execa: 8.0.1 @@ -7739,6 +7949,8 @@ snapshots: '@netlify/types@2.2.0': {} + '@netlify/types@2.6.0': {} + '@netlify/zip-it-and-ship-it@14.1.14': dependencies: '@babel/parser': 7.28.5 @@ -8164,6 +8376,70 @@ snapshots: dependencies: escape-string-regexp: 5.0.0 + '@slack/bolt@4.7.2': + dependencies: + '@slack/logger': 4.0.1 + '@slack/oauth': 3.0.5 + '@slack/socket-mode': 2.0.7 + '@slack/types': 2.21.0 + '@slack/web-api': 7.15.2 + axios: 1.16.0 + express: 5.2.1 + path-to-regexp: 8.4.2 + raw-body: 3.0.1 + tsscmp: 1.0.6 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@slack/logger@4.0.1': + dependencies: + '@types/node': 24.12.2 + + '@slack/oauth@3.0.5': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.2 + '@types/jsonwebtoken': 9.0.10 + '@types/node': 24.12.2 + jsonwebtoken: 9.0.2 + transitivePeerDependencies: + - debug + + '@slack/socket-mode@2.0.7': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.2 + '@types/node': 24.12.2 + '@types/ws': 8.18.1 + eventemitter3: 5.0.4 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + + '@slack/types@2.21.0': {} + + '@slack/web-api@7.15.2': + dependencies: + '@slack/logger': 4.0.1 + '@slack/types': 2.21.0 + '@types/node': 24.12.2 + '@types/retry': 0.12.0 + axios: 1.16.0 + eventemitter3: 5.0.4 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@so-ric/colorspace@1.1.6': dependencies: color: 5.0.3 @@ -8234,6 +8510,11 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.2 + '@types/leaflet.markercluster@1.5.6': dependencies: '@types/leaflet': 1.9.21 @@ -8265,7 +8546,6 @@ snapshots: '@types/node@24.12.2': dependencies: undici-types: 7.16.0 - optional: true '@types/normalize-package-data@2.4.4': {} @@ -8281,12 +8561,16 @@ snapshots: '@types/require-dir@1.0.4': {} + '@types/retry@0.12.0': {} + '@types/retry@0.12.2': {} '@types/sanitize-html@2.16.0': dependencies: htmlparser2: 8.0.2 + '@types/slackify-html@1.0.8': {} + '@types/triple-beam@1.3.5': {} '@types/ungap__structured-clone@1.2.0': {} @@ -8295,6 +8579,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.2 + '@types/yauzl@2.10.3': dependencies: '@types/node': 24.10.4 @@ -8755,6 +9043,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -9008,6 +9301,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} atomically@2.1.0: @@ -9026,6 +9321,14 @@ snapshots: axe-core@4.11.0: {} + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} b4a@1.7.3: {} @@ -9085,6 +9388,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.0 + iconv-lite: 0.7.1 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bootstrap@4.6.2(jquery@3.7.1)(popper.js@1.16.1): @@ -9322,6 +9639,10 @@ snapshots: colors@1.4.0: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -9381,12 +9702,16 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} cookie-es@1.2.2: {} cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.7.1: {} cookie@0.7.2: {} @@ -9564,6 +9889,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -10234,6 +10561,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -10306,6 +10635,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext-list@2.2.2: dependencies: mime-db: 1.54.0 @@ -10477,6 +10839,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-my-way@8.2.2: dependencies: fast-deep-equal: 3.1.3 @@ -10518,6 +10891,8 @@ snapshots: optionalDependencies: debug: 4.4.3(supports-color@5.5.0) + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -10529,6 +10904,14 @@ snapshots: form-data-encoder@2.1.4: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + format@0.2.2: {} formdata-polyfill@4.0.10: @@ -10539,6 +10922,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + from2@2.3.0: dependencies: inherits: 2.0.4 @@ -10908,6 +11293,8 @@ snapshots: html-element-attributes@2.3.0: {} + html-entities@1.4.0: {} + html-void-elements@2.0.1: {} htmlparser2@8.0.2: @@ -10917,6 +11304,8 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + htmlparser@1.7.7: {} + http-cache-semantics@4.2.0: {} http-errors@1.8.1: @@ -10935,6 +11324,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-middleware@2.0.9(debug@4.4.3): dependencies: '@types/http-proxy': 1.17.17 @@ -11191,6 +11588,8 @@ snapshots: is-docker@3.0.0: {} + is-electron@2.2.2: {} + is-error-instance@2.0.0: {} is-extglob@2.1.1: {} @@ -11259,6 +11658,8 @@ snapshots: is-plain-object@5.0.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -11819,10 +12220,14 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + memoize-one@6.0.0: {} merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-options@3.0.4: dependencies: is-plain-obj: 2.1.0 @@ -12194,6 +12599,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} @@ -12286,6 +12695,8 @@ snapshots: negotiator@0.6.3: {} + negotiator@1.0.0: {} + neo-async@2.6.2: optional: true @@ -12671,6 +13082,8 @@ snapshots: dependencies: p-map: 7.0.3 + p-finally@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -12697,14 +13110,28 @@ snapshots: p-map@7.0.3: {} + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + p-reduce@3.0.0: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-retry@6.2.1: dependencies: '@types/retry': 0.12.2 is-network-error: 1.3.0 retry: 0.13.1 + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + p-timeout@5.1.0: {} p-timeout@6.1.4: {} @@ -12788,6 +13215,8 @@ snapshots: path-to-regexp@0.1.12: {} + path-to-regexp@8.4.2: {} + path-type@6.0.0: {} pathe@1.1.2: {} @@ -12979,6 +13408,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + ps-list@8.1.1: {} pstree.remy@1.1.8: {} @@ -13005,6 +13436,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -13348,6 +13783,16 @@ snapshots: rfdc@1.4.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} run-async@2.4.1: {} @@ -13462,6 +13907,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serve-static@1.16.2: dependencies: encodeurl: 2.0.0 @@ -13471,6 +13932,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -13578,6 +14048,11 @@ snapshots: dependencies: semver: 7.7.3 + slackify-html@1.0.1: + dependencies: + html-entities: 1.4.0 + htmlparser: 1.7.7 + slash@5.1.0: {} slashes@3.0.12: {} @@ -13648,6 +14123,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -14023,6 +14500,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -14043,6 +14522,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 diff --git a/scripts/build-rooms.ts b/scripts/build-rooms.ts new file mode 100644 index 00000000..86405d80 --- /dev/null +++ b/scripts/build-rooms.ts @@ -0,0 +1,33 @@ +import 'dotenv/config'; +import Airtable from 'airtable'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; + +const outDir = path.join('.', 'data'); +const outFile = path.join(outDir, 'rooms.json'); + +async function main() { + mkdirSync(outDir, { recursive: true }); + + if (!process.env.AIRTABLE_COWORKING_BASE) { + console.warn( + '[build-rooms] AIRTABLE_COWORKING_BASE not set — writing empty rooms.json. The zoom-meeting-webhook-handler will not match any meetings until this is configured.', + ); + writeFileSync(outFile, '[]\n'); + return; + } + + console.log('Building rooms'); + const base = new Airtable().base(process.env.AIRTABLE_COWORKING_BASE); + const results = await base('rooms').select().all(); + + const rooms = results.map((record) => ({ + ...record.fields, + record_id: record.id, + })); + + writeFileSync(outFile, JSON.stringify(rooms, null, 2)); + console.log(`Done building ${rooms.length} rooms`); +} + +main(); From ff5d4e5acb18b14eca5779b45e31d7f3775a4f1e Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 5 May 2026 14:51:41 -0400 Subject: [PATCH 2/9] feat: add Slack, Zoom, and event-reminder webhook functions Consolidates the Netlify Functions previously hosted in the standalone webhooks repo (typescript-migration branch) into this codebase: - slack/: unified Slack Events API + interactivity handler with custom Bolt receiver, signature verification, and team_join welcome flow. - zoom-meeting-webhook-handler/: handles meeting.{started,ended, participant_joined,participant_left}, syncing co-working room state to Slack threads and Airtable room_instances records. - event-reminders-{daily,hourly,weekly}/: scheduled functions that pull upcoming events from the CMS GraphQL API and post Block Kit messages to Slack. Schedules declared in-function via export const config. - _shared/: HMAC verification helpers, Slack Web client wrapper, requireEnv helper, and CMS/Room types reused across functions. - netlify/env.d.ts: typed ProcessEnv declarations for the new vars. --- netlify/env.d.ts | 31 ++ netlify/functions/_shared/env.ts | 7 + netlify/functions/_shared/slack.ts | 18 ++ netlify/functions/_shared/types/cms.ts | 22 ++ netlify/functions/_shared/types/room.ts | 15 + netlify/functions/_shared/verify.ts | 77 +++++ .../functions/event-reminders-daily/index.ts | 162 ++++++++++ .../functions/event-reminders-hourly/index.ts | 290 ++++++++++++++++++ .../functions/event-reminders-weekly/index.ts | 153 +++++++++ netlify/functions/slack/index.ts | 128 ++++++++ netlify/functions/slack/messages.ts | 161 ++++++++++ .../zoom-meeting-webhook-handler/airtable.ts | 42 +++ .../zoom-meeting-webhook-handler/index.ts | 165 ++++++++++ .../zoom-meeting-webhook-handler/slack.ts | 116 +++++++ 14 files changed, 1387 insertions(+) create mode 100644 netlify/env.d.ts create mode 100644 netlify/functions/_shared/env.ts create mode 100644 netlify/functions/_shared/slack.ts create mode 100644 netlify/functions/_shared/types/cms.ts create mode 100644 netlify/functions/_shared/types/room.ts create mode 100644 netlify/functions/_shared/verify.ts create mode 100644 netlify/functions/event-reminders-daily/index.ts create mode 100644 netlify/functions/event-reminders-hourly/index.ts create mode 100644 netlify/functions/event-reminders-weekly/index.ts create mode 100644 netlify/functions/slack/index.ts create mode 100644 netlify/functions/slack/messages.ts create mode 100644 netlify/functions/zoom-meeting-webhook-handler/airtable.ts create mode 100644 netlify/functions/zoom-meeting-webhook-handler/index.ts create mode 100644 netlify/functions/zoom-meeting-webhook-handler/slack.ts diff --git a/netlify/env.d.ts b/netlify/env.d.ts new file mode 100644 index 00000000..1f32cfc7 --- /dev/null +++ b/netlify/env.d.ts @@ -0,0 +1,31 @@ +declare namespace NodeJS { + interface ProcessEnv { + // CMS + CMS_URL?: string; + CMS_TOKEN?: string; + + // Slack + SLACK_BOT_TOKEN?: string; + SLACK_SIGNING_SECRET?: string; + SLACK_ANNOUNCEMENTS_CHANNEL?: string; + SLACK_EVENT_ADMIN_CHANNEL?: string; + SLACK_JOIN_LINK?: string; + + // Zoom + ZOOM_WEBHOOK_SECRET_TOKEN?: string; + ZOOM_WEBHOOK_AUTH?: string; + ZOOM_TUESDAYS?: string; + ZOOM_THURSDAYS?: string; + + // Airtable + AIRTABLE_COWORKING_BASE?: string; + + // Test overrides + TEST_SLACK_BOT_TOKEN?: string; + TEST_SLACK_SIGNING_SECRET?: string; + TEST_SLACK_ANNOUNCEMENTS_CHANNEL?: string; + TEST_SLACK_EVENT_ADMIN_CHANNEL?: string; + TEST_ZOOM_WEBHOOK_SECRET_TOKEN?: string; + TEST_ZOOM_WEBHOOK_AUTH?: string; + } +} diff --git a/netlify/functions/_shared/env.ts b/netlify/functions/_shared/env.ts new file mode 100644 index 00000000..8c8c5681 --- /dev/null +++ b/netlify/functions/_shared/env.ts @@ -0,0 +1,7 @@ +export function requireEnv(name: keyof typeof process.env): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} diff --git a/netlify/functions/_shared/slack.ts b/netlify/functions/_shared/slack.ts new file mode 100644 index 00000000..60839568 --- /dev/null +++ b/netlify/functions/_shared/slack.ts @@ -0,0 +1,18 @@ +import { webApi } from '@slack/bolt'; + +const SLACK_BOT_TOKEN = + process.env.TEST_SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN; + +const web = new webApi.WebClient(SLACK_BOT_TOKEN); + +export async function postMessage(message: webApi.ChatPostMessageArguments) { + return web.chat.postMessage(message); +} + +export async function updateMessage(message: webApi.ChatUpdateArguments) { + return web.chat.update(message); +} + +export async function publishView(message: webApi.ViewsPublishArguments) { + return web.views.publish(message); +} diff --git a/netlify/functions/_shared/types/cms.ts b/netlify/functions/_shared/types/cms.ts new file mode 100644 index 00000000..f1f3e30d --- /dev/null +++ b/netlify/functions/_shared/types/cms.ts @@ -0,0 +1,22 @@ +export interface CalendarsResponse { + solspace_calendar: { + calendars: Array<{ handle: string }>; + }; +} + +export interface CalendarEvent { + id: string; + title: string; + startDateLocalized: string; + endDateLocalized: string; + eventCalendarDescription: string; + eventJoinLink?: string; + eventZoomHostCode?: string; + eventSlackAnnouncementsChannelId?: string; +} + +export interface EventsResponse { + solspace_calendar: { + events: CalendarEvent[]; + }; +} diff --git a/netlify/functions/_shared/types/room.ts b/netlify/functions/_shared/types/room.ts new file mode 100644 index 00000000..8ce90030 --- /dev/null +++ b/netlify/functions/_shared/types/room.ts @@ -0,0 +1,15 @@ +export interface Room { + ZoomMeetingId: number; + SlackChannelId: string; + ZoomMeetingInviteUrl: string; + MessageSessionStarted: string; + MessageSessionEnded: string; + ButtonJoin: string; + ButtonStartNew: string; + NoticeTitle: string; + NoticeBody: string; + NoticeConfirm: string; + NoticeCancel: string; + ContextBody?: string; + record_id: string; +} diff --git a/netlify/functions/_shared/verify.ts b/netlify/functions/_shared/verify.ts new file mode 100644 index 00000000..96bfa1b5 --- /dev/null +++ b/netlify/functions/_shared/verify.ts @@ -0,0 +1,77 @@ +import crypto from 'node:crypto'; + +/** + * Core HMAC-SHA256 verification. Computes `v0=HMAC(secret, message)` and + * performs a timing-safe comparison against the expected signature. + */ +export function verifyHmacSignature( + secret: string, + message: string, + expectedSignature: string, +): boolean { + const computed = + 'v0=' + + crypto.createHmac('sha256', secret).update(message, 'utf8').digest('hex'); + + if (computed.length !== expectedSignature.length) { + return false; + } + + return crypto.timingSafeEqual( + Buffer.from(computed, 'utf8'), + Buffer.from(expectedSignature, 'utf8'), + ); +} + +/** + * Verifies a Slack request signature. Checks timestamp staleness (>300s) + * then validates the HMAC signature. + */ +export function verifySlackRequest( + rawBody: string, + headers: Headers, + secret: string, +): { valid: true } | { valid: false; reason: string } { + const slackSignature = headers.get('x-slack-signature'); + const timestamp = headers.get('x-slack-request-timestamp'); + + const time = Math.floor(Date.now() / 1000); + if (!timestamp || Math.abs(time - Number(timestamp)) > 300) { + return { valid: false, reason: 'Ignore this request.' }; + } + + const message = `v0:${timestamp}:${rawBody}`; + + if (slackSignature && verifyHmacSignature(secret, message, slackSignature)) { + return { valid: true }; + } + + return { valid: false, reason: 'Verification Failed.' }; +} + +/** + * Verifies a Zoom webhook signature using the x-zm-signature header. + */ +export function verifyZoomSignature( + rawBody: string, + headers: Headers, + secret: string, +): boolean { + const zmSignature = headers.get('x-zm-signature'); + const zmTimestamp = headers.get('x-zm-request-timestamp'); + + if (!zmSignature || !zmTimestamp) { + return false; + } + + const message = `v0:${zmTimestamp}:${rawBody}`; + return verifyHmacSignature(secret, message, zmSignature); +} + +/** + * Computes an HMAC-SHA256 hex digest. Used for Zoom's endpoint URL + * validation challenge response. + */ +export function hmacSha256Hex(secret: string, data: string): string { + return crypto.createHmac('sha256', secret).update(data).digest('hex'); +} diff --git a/netlify/functions/event-reminders-daily/index.ts b/netlify/functions/event-reminders-daily/index.ts new file mode 100644 index 00000000..beab76ca --- /dev/null +++ b/netlify/functions/event-reminders-daily/index.ts @@ -0,0 +1,162 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { DateTime } from 'luxon'; +import { postMessage } from '../_shared/slack'; +import slackify from 'slackify-html'; +import { requireEnv } from '../_shared/env'; +import type { Config } from '@netlify/functions'; +import type { CalendarsResponse, EventsResponse } from '../_shared/types/cms'; + +const SLACK_ANNOUNCEMENTS_CHANNEL = + process.env.TEST_SLACK_ANNOUNCEMENTS_CHANNEL || + requireEnv('SLACK_ANNOUNCEMENTS_CHANNEL'); + +const DEFAULT_SLACK_EVENT_CHANNEL = 'C017WAKN883'; + +const calendarsQuery = gql` + query getCalendars { + solspace_calendar { + calendars { + handle + } + } + } +`; + +function createEventsQuery(calendars: CalendarsResponse) { + return gql` + query getEvents($rangeStart: String!, $rangeEnd: String!) { + solspace_calendar { + events(rangeStart: $rangeStart, rangeEnd: $rangeEnd) { + id + title + startDateLocalized + endDateLocalized + ${calendars.solspace_calendar.calendars.map( + ({ handle }) => ` + ... on ${handle}_Event { + eventCalendarDescription + eventJoinLink + eventZoomHostCode + eventSlackAnnouncementsChannelId + id + } + `, + )} + } + } + } +`; +} + +export default async (req: Request) => { + const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + + const rangeStart = DateTime.now().setZone('America/New_York').toISO(); + const rangeEnd = DateTime.now() + .setZone('America/New_York') + .plus({ days: 1 }) + .toISO(); + + console.log('Fetching events', rangeStart, rangeEnd); + + try { + const calendarsResponse = + await graphQLClient.request(calendarsQuery); + + const eventsResponse = await graphQLClient.request( + createEventsQuery(calendarsResponse), + { + rangeStart, + rangeEnd, + }, + ); + + const eventsList = eventsResponse.solspace_calendar.events; + if (eventsList && eventsList.length) { + const dayCheck = new Date(); + if (dayCheck.getDay() === 1) { + // don't run this one on monday, since the weekly one runs on monday + return; + } + + const dailyMessage = { + channel: SLACK_ANNOUNCEMENTS_CHANNEL, + text: `Today's events are: ${eventsList + .map((event) => { + return `${event.title}: ${DateTime.fromISO( + event.startDateLocalized, + ).toFormat('EEEE, fff')}`; + }) + .join(', ')}`, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'header' as const, + text: { + type: 'plain_text' as const, + text: "📆 Today's Events Are:", + emoji: true, + }, + }, + ...eventsList.reduce[]>((list, event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + return [ + ...list, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: slackify(event.eventCalendarDescription), + }, + ], + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `â„šī¸ Link to join will be posted in <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }> about 10 minutes before the event starts.`, + }, + ], + }, + { + type: 'divider', + }, + ]; + }, []), + ], + }; + + await postMessage(dailyMessage); + } + + return new Response(null, { status: 200 }); + } catch (e) { + console.error(e); + return new Response(null, { status: 500 }); + } +}; + +export const config: Config = { + schedule: '0 12 * * *', +}; diff --git a/netlify/functions/event-reminders-hourly/index.ts b/netlify/functions/event-reminders-hourly/index.ts new file mode 100644 index 00000000..d1f87f28 --- /dev/null +++ b/netlify/functions/event-reminders-hourly/index.ts @@ -0,0 +1,290 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { DateTime } from 'luxon'; +import { postMessage } from '../_shared/slack'; +import slackify from 'slackify-html'; +import { requireEnv } from '../_shared/env'; +import type { Config } from '@netlify/functions'; +import type { CalendarsResponse, EventsResponse } from '../_shared/types/cms'; + +const SLACK_ANNOUNCEMENTS_CHANNEL = + process.env.TEST_SLACK_ANNOUNCEMENTS_CHANNEL || + requireEnv('SLACK_ANNOUNCEMENTS_CHANNEL'); + +const SLACK_EVENT_ADMIN_CHANNEL = + process.env.TEST_SLACK_EVENT_ADMIN_CHANNEL || + requireEnv('SLACK_EVENT_ADMIN_CHANNEL'); + +const DEFAULT_SLACK_EVENT_CHANNEL = 'C017WAKN883'; + +const calendarsQuery = gql` + query getCalendars { + solspace_calendar { + calendars { + handle + } + } + } +`; + +function createEventsQuery(calendars: CalendarsResponse) { + return gql` + query getEvents($rangeStart: String!, $rangeEnd: String!) { + solspace_calendar { + events(rangeStart: $rangeStart, rangeEnd: $rangeEnd) { + id + title + startDateLocalized + endDateLocalized + ${calendars.solspace_calendar.calendars.map( + ({ handle }) => ` + ... on ${handle}_Event { + eventCalendarDescription + eventJoinLink + eventZoomHostCode + id + eventSlackAnnouncementsChannelId + } + `, + )} + } + } + } +`; +} + +export default async (req: Request) => { + const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + + const rangeStart = DateTime.now() + .setZone('America/New_York') + .set({ hour: 0 }) + .toISO(); + const rangeEnd = DateTime.now() + .setZone('America/New_York') + .plus({ hours: 1 }) + .toISO(); + + console.log('Fetching events', rangeStart, rangeEnd); + + try { + const calendarsResponse = + await graphQLClient.request(calendarsQuery); + + const eventsResponse = await graphQLClient.request( + createEventsQuery(calendarsResponse), + { + rangeStart, + rangeEnd, + }, + ); + + const eventsList = eventsResponse.solspace_calendar.events; + if (eventsList && eventsList.length) { + // filter out past events + const now = DateTime.now(); + const filteredList = eventsList.filter((event) => { + return now < DateTime.fromISO(event.startDateLocalized); + }); + + if (filteredList.length) { + const hourlyMessages = filteredList.map((event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + + const blocks: Record[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: '⏰ Starting Soon:', + emoji: true, + }, + }, + ]; + + const titleBlock: Record = { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }; + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) === 'http' + ) { + titleBlock.accessory = { + type: 'button', + text: { + type: 'plain_text', + text: 'Join Event', + emoji: true, + }, + value: `join_event_${event.id}`, + url: event.eventJoinLink, + action_id: 'button-join-event', + }; + } + + blocks.push(titleBlock); + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) !== 'http' + ) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Location:* ${event.eventJoinLink}`, + }, + }); + } + + blocks.push( + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: slackify(event.eventCalendarDescription), + }, + ], + }, + { + type: 'divider', + }, + ); + + return { + channel: + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL, + text: `Starting soon: ${event.title}: ${eventDate.toFormat( + 'EEEE, fff', + )}`, + unfurl_links: false, + unfurl_media: false, + blocks, + }; + }); + + const hourlyAdminMessage = { + channel: SLACK_EVENT_ADMIN_CHANNEL, + text: `Starting soon: ${filteredList + .map((event) => { + return `${event.title}: ${DateTime.fromISO( + event.startDateLocalized, + ).toFormat('EEEE, fff')}`; + }) + .join(', ')}`, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'header' as const, + text: { + type: 'plain_text' as const, + text: '⏰ Starting Soon:', + emoji: true, + }, + }, + ...filteredList.reduce[]>( + (list, event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + + const titleBlock: Record = { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }; + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) === 'http' + ) { + titleBlock.accessory = { + type: 'button', + text: { + type: 'plain_text', + text: 'Join Event', + emoji: true, + }, + value: `join_event_${event.id}`, + url: event.eventJoinLink, + action_id: 'button-join-event', + }; + } + + return [ + ...list, + titleBlock, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Location:* ${event.eventJoinLink}`, + }, + }, + ...(event.eventZoomHostCode + ? [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Host Code:* ${event.eventZoomHostCode}`, + }, + }, + ] + : []), + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Announcement posted to:* <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }>`, + }, + }, + { + type: 'divider', + }, + ]; + }, + [], + ), + ], + }; + + await postMessage(hourlyAdminMessage); + + await Promise.all( + hourlyMessages.map((message) => postMessage(message)), + ); + } + } + return new Response(null, { status: 200 }); + } catch (e) { + console.error(e); + return new Response(null, { status: 500 }); + } +}; + +export const config: Config = { + schedule: '50 * * * *', +}; diff --git a/netlify/functions/event-reminders-weekly/index.ts b/netlify/functions/event-reminders-weekly/index.ts new file mode 100644 index 00000000..c446d4c9 --- /dev/null +++ b/netlify/functions/event-reminders-weekly/index.ts @@ -0,0 +1,153 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { DateTime } from 'luxon'; +import { postMessage } from '../_shared/slack'; +import { requireEnv } from '../_shared/env'; +import type { Config } from '@netlify/functions'; +import type { CalendarsResponse, EventsResponse } from '../_shared/types/cms'; + +const SLACK_ANNOUNCEMENTS_CHANNEL = + process.env.TEST_SLACK_ANNOUNCEMENTS_CHANNEL || + requireEnv('SLACK_ANNOUNCEMENTS_CHANNEL'); + +const DEFAULT_SLACK_EVENT_CHANNEL = 'C017WAKN883'; + +const calendarsQuery = gql` + query getCalendars { + solspace_calendar { + calendars { + handle + } + } + } +`; + +function createEventsQuery(calendars: CalendarsResponse) { + return gql` + query getEvents($rangeStart: String!, $rangeEnd: String!) { + solspace_calendar { + events(rangeStart: $rangeStart, rangeEnd: $rangeEnd) { + id + title + startDateLocalized + endDateLocalized + ${calendars.solspace_calendar.calendars.map( + ({ handle }) => ` + ... on ${handle}_Event { + eventCalendarDescription + eventJoinLink + eventZoomHostCode + eventSlackAnnouncementsChannelId + id + } + `, + )} + } + } + } +`; +} + +export default async (req: Request) => { + const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + + const rangeStart = DateTime.now() + .setZone('America/New_York') + .set({ hour: 0 }) + .toISO(); + const rangeEnd = DateTime.now() + .setZone('America/New_York') + .plus({ weeks: 1 }) + .toISO(); + + console.log('Fetching events', rangeStart, rangeEnd); + + try { + const calendarsResponse = + await graphQLClient.request(calendarsQuery); + + const eventsResponse = await graphQLClient.request( + createEventsQuery(calendarsResponse), + { + rangeStart, + rangeEnd, + }, + ); + + const eventsList = eventsResponse.solspace_calendar.events; + if (eventsList && eventsList.length) { + const weeklyMessage = { + channel: SLACK_ANNOUNCEMENTS_CHANNEL, + text: `This weeks events are: ${eventsList + .map((event) => { + return `${event.title}: ${DateTime.fromISO( + event.startDateLocalized, + ).toFormat('EEEE, fff')}`; + }) + .join(', ')}`, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'header' as const, + text: { + type: 'plain_text' as const, + text: "📆 This Week's Events Are:", + emoji: true, + }, + }, + ...eventsList.map((event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + // TODO - colate these by date + return { + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: `** in <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }>\n${event.title}`, + }, + }; + }), + { + type: 'context' as const, + elements: [ + { + type: 'mrkdwn' as const, + text: `â„šī¸ Links to join will be posted in the specified channel about 10 minutes before the event starts.`, + }, + ], + }, + { + type: 'divider' as const, + }, + { + type: 'context' as const, + elements: [ + { + type: 'mrkdwn' as const, + text: `See details and more events at !`, + }, + ], + }, + ], + }; + + await postMessage(weeklyMessage); + } + return new Response(null, { status: 200 }); + } catch (e) { + console.error(e); + return new Response(null, { status: 500 }); + } +}; + +export const config: Config = { + schedule: '0 12 * * 1', +}; diff --git a/netlify/functions/slack/index.ts b/netlify/functions/slack/index.ts new file mode 100644 index 00000000..af521086 --- /dev/null +++ b/netlify/functions/slack/index.ts @@ -0,0 +1,128 @@ +import querystring from 'node:querystring'; +import { App } from '@slack/bolt'; +import type { Receiver, ReceiverEvent } from '@slack/bolt'; +import { verifySlackRequest } from '../_shared/verify'; +import { requireEnv } from '../_shared/env'; +import * as messages from './messages'; + +const SLACK_BOT_TOKEN = + process.env.TEST_SLACK_BOT_TOKEN || requireEnv('SLACK_BOT_TOKEN'); +const SLACK_SIGNING_SECRET = + process.env.TEST_SLACK_SIGNING_SECRET || requireEnv('SLACK_SIGNING_SECRET'); + +/** + * Custom Bolt receiver for Netlify Functions. + * Handles signature verification, body parsing (JSON + form-encoded), + * url_verification challenges, and ReceiverEvent construction. + */ +class NetlifyReceiver implements Receiver { + private app?: App; + private signingSecret: string; + + constructor(signingSecret: string) { + this.signingSecret = signingSecret; + } + + init(app: App) { + this.app = app; + } + + start() { + return Promise.resolve(); + } + + stop() { + return Promise.resolve(); + } + + async handleRequest(req: Request): Promise { + const rawBody = await req.text(); + const contentType = req.headers.get('content-type') ?? ''; + const body = this.parseBody(rawBody, contentType); + + // Slack sends this once when configuring the Events API URL + if (body.type === 'url_verification') { + return new Response(JSON.stringify({ challenge: body.challenge }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const isValid = verifySlackRequest(rawBody, req.headers, this.signingSecret); + if (!isValid.valid) { + console.log('Failed validation:', isValid.reason); + return new Response(isValid.reason, { status: 401 }); + } + + let storedResponse: string | undefined; + + const event: ReceiverEvent = { + body, + ack: async (response) => { + if (typeof response === 'undefined' || response == null) { + storedResponse = ''; + } else if (typeof response === 'string') { + storedResponse = response; + } else { + storedResponse = JSON.stringify(response); + } + }, + retryNum: req.headers.get('x-slack-retry-num') + ? Number(req.headers.get('x-slack-retry-num')) + : undefined, + retryReason: req.headers.get('x-slack-retry-reason') ?? undefined, + }; + + try { + await this.app!.processEvent(event); + + if (storedResponse !== undefined) { + return new Response(storedResponse, { + status: 200, + headers: storedResponse + ? { 'Content-Type': 'application/json' } + : undefined, + }); + } + } catch (error) { + console.error('Error processing Slack event:', error); + return new Response('Internal server error', { status: 500 }); + } + + return new Response('', { status: 404 }); + } + + private parseBody( + rawBody: string, + contentType: string, + ): Record { + if (contentType.includes('application/x-www-form-urlencoded')) { + const parsed = querystring.parse(rawBody); + if (typeof parsed.payload === 'string') { + return JSON.parse(parsed.payload); + } + return parsed; + } + return JSON.parse(rawBody); + } +} + +const receiver = new NetlifyReceiver(SLACK_SIGNING_SECRET); + +const app = new App({ + token: SLACK_BOT_TOKEN, + receiver, + processBeforeResponse: true, +}); + +app.event('team_join', async ({ event, client }) => { + const msg = messages.welcome({ event }); + await client.chat.postMessage(msg); +}); + +app.event('app_home_opened', async ({ event, client }) => { + const view = messages.appHome({ event }); + await client.views.publish(view); +}); + +export default async (req: Request) => receiver.handleRequest(req); diff --git a/netlify/functions/slack/messages.ts b/netlify/functions/slack/messages.ts new file mode 100644 index 00000000..00ae8402 --- /dev/null +++ b/netlify/functions/slack/messages.ts @@ -0,0 +1,161 @@ +interface SlackUser { + id: string; + name: string; +} + +interface TeamJoinEvent { + user: SlackUser; +} + +interface AppHomeOpenedEvent { + user: string; +} + +function getWelcomeBlocks(user?: SlackUser) { + return [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:wave: Hey ${ + user ? `@${user.name}` : `there` + }, welcome to Virtual Coffee -- fondly referred to as VC around this space.`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ":heart: Before doing anything else, please first take a moment to read our . Our Code of Conduct is in effect at any Virtual Coffee function, including direct messages. If you have experienced or witnessed violations to Virtual Coffee's Code of Conduct, please use our to let us know.", + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'header', + text: { + type: 'plain_text', + text: 'Now for the fun part!', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'We have a lot going on here, but here are some places you might want to start:', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:white_check_mark: Head over to #welcome and introduce yourself to the rest of the group! Let us know what you like to do in your freetime, what you're doing in tech, and a random fact about your life!`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':dart: Check out #monthly-challenge to see what the community is working on together right now.', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':mega: The #announcements channel has the most recent news on events and initiatives happening in the community.', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ":computer: Our #co-working-room is a zoom room that's open all day, every day for members to quietly work, pair on solving problems, or just say hello.", + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':question: #help-and-pairing is the space for asking questions about any and all tech related topics. But if you have a general question, we have a really welcoming community, so feel free to throw it in the channel that looks best.', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + // Member ids (use ids in case username changes): + // - Julia: U01JXQGMSUC + // - Kirk: U01577R42TS + // - Bekah: U014HT3RNCU + // - Dan: U0157K5MUPJ + // - Meg: U01B9NQF2PR + { + type: 'section', + text: { + type: 'mrkdwn', + text: ":heart: And remember, you can always message one of our community maintainers, <@U014HT3RNCU>, <@U0157K5MUPJ>, <@U01577R42TS>, <@U01JXQGMSUC>, or <@U01B9NQF2PR> for any help and support you may need. \n\n *We're happy to have you here!*", + }, + }, + ]; +} + +export function appHome({ event }: { event: AppHomeOpenedEvent }) { + return { + user_id: event.user, + view: { + type: 'home' as const, + blocks: getWelcomeBlocks(), + }, + }; +} + +export function welcome({ event }: { event: TeamJoinEvent }) { + return { + link_names: true, + unfurl_links: false, + unfurl_media: false, + channel: event.user.id, + text: `:wave: Hey @${event.user.name}, welcome to Virtual Coffee -- fondly referred to as VC around this space.`, + blocks: getWelcomeBlocks(event.user), + }; +} diff --git a/netlify/functions/zoom-meeting-webhook-handler/airtable.ts b/netlify/functions/zoom-meeting-webhook-handler/airtable.ts new file mode 100644 index 00000000..b13c6183 --- /dev/null +++ b/netlify/functions/zoom-meeting-webhook-handler/airtable.ts @@ -0,0 +1,42 @@ +import type { Room } from '../_shared/types/room'; +import type Airtable from 'airtable'; + +type AirtableBase = ReturnType['base']>; + +// returns a roomInstance record, or undefined. +// Will retry 5 times, pausing 1 second between tries. +export async function findRoomInstance( + room: Room, + base: AirtableBase, + instanceId: string, +) { + async function tryFind() { + const resultArray = await base('room_instances') + .select({ + // Selecting the first 1 records in Grid view: + maxRecords: 1, + view: 'Grid view', + filterByFormula: `AND(RoomZoomMeetingId='${room.ZoomMeetingId}',instance_uuid='${instanceId}')`, + }) + .firstPage(); + + return resultArray[0]; + } + function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + let roomInstance = await tryFind(); + let count = 0; + while (count < 5 && !roomInstance) { + count++; + await sleep(400 * count); + roomInstance = await tryFind(); + } + + if (!roomInstance) { + console.log(`room instance ${instanceId} not found`); + } + + return roomInstance; +} diff --git a/netlify/functions/zoom-meeting-webhook-handler/index.ts b/netlify/functions/zoom-meeting-webhook-handler/index.ts new file mode 100644 index 00000000..0e4c44f5 --- /dev/null +++ b/netlify/functions/zoom-meeting-webhook-handler/index.ts @@ -0,0 +1,165 @@ +import Airtable from 'airtable'; +import { updateMeetingStatus, updateMeetingAttendence } from './slack'; +import { findRoomInstance } from './airtable'; +import { requireEnv } from '../_shared/env'; +import { verifyZoomSignature, hmacSha256Hex } from '../_shared/verify'; +import type { Room } from '../_shared/types/room'; + +import rooms from '../../../data/rooms.json' with { type: 'json' }; +const typedRooms = rooms as Room[]; + +const EVENT_MEETING_STARTED = 'meeting.started'; +const EVENT_MEETING_ENDED = 'meeting.ended'; +const EVENT_PARTICIPANT_JOINED = 'meeting.participant_joined'; +const EVENT_PARTICIPANT_LEFT = 'meeting.participant_left'; + +const ZOOM_SECRET = + process.env.TEST_ZOOM_WEBHOOK_SECRET_TOKEN || + requireEnv('ZOOM_WEBHOOK_SECRET_TOKEN'); + +const ZOOM_AUTH = + process.env.TEST_ZOOM_WEBHOOK_AUTH || requireEnv('ZOOM_WEBHOOK_AUTH'); + +export default async (req: Request) => { + try { + const rawBody = await req.text(); + + /** + * verification. zoom will either send an authorization header or a x-zm-signature header + */ + + const authorized = + verifyZoomSignature(rawBody, req.headers, ZOOM_SECRET) || + req.headers.get('authorization') === ZOOM_AUTH; + + if (!authorized) { + console.log('Unauthorized'); + return new Response('', { status: 401 }); + } + + const request = JSON.parse(rawBody); + + if (request.event == 'endpoint.url_validation') { + const hashForValidate = hmacSha256Hex( + ZOOM_SECRET, + request.payload.plainToken, + ); + return new Response( + JSON.stringify({ + plainToken: request.payload.plainToken, + encryptedToken: hashForValidate, + }), + { status: 200 }, + ); + } + + // check our meeting ID. The meeting ID never changes, but the uuid is different for each instance + + const room = typedRooms.find( + (room) => room.ZoomMeetingId === request.payload.object.id, + ); + console.log('incoming request'); + console.log('request payload'); + console.log(request.payload.object); + console.log('request event'); + console.log(request.event); + + if (room) { + const base = new Airtable().base(requireEnv('AIRTABLE_COWORKING_BASE')); + + switch (request.event) { + case EVENT_PARTICIPANT_JOINED: + case EVENT_PARTICIPANT_LEFT: { + const roomInstance = await findRoomInstance( + room, + base, + request.payload.object.uuid, + ); + + if (roomInstance) { + // create room event record + console.log(`found room instance ${roomInstance.getId()}`); + + await updateMeetingAttendence( + room, + roomInstance.get('slack_thread_timestamp') as string, + request, + ); + } + + break; + } + + case EVENT_MEETING_STARTED: { + // post message to Slack and get result + console.log('posting update'); + const result = await updateMeetingStatus(room); + console.log('done posting update'); + + // create new room instance + const created = await base('room_instances').create({ + instance_uuid: request.payload.object.uuid, + slack_thread_timestamp: result.ts, + start_time: request.payload.object.start_time, + room_record: [room.record_id], + }); + + if (!created) { + throw new Error('no record created'); + } + + console.log(`room_event created: ${created.getId()}`); + + break; + } + + case EVENT_MEETING_ENDED: { + const roomInstanceEnd = await findRoomInstance( + room, + base, + request.payload.object.uuid, + ); + + if (roomInstanceEnd) { + await updateMeetingStatus( + room, + roomInstanceEnd.get('slack_thread_timestamp') as string, + ); + + // update room instance + const updated = await base('room_instances').update( + roomInstanceEnd.getId(), + { + end_time: request.payload.object.end_time, + }, + ); + + if (!updated) { + throw new Error('no record updated'); + } + + console.log(`room_event updated: ${updated.getId()}`); + } + + break; + } + + default: + break; + } + } else { + console.log('meeting ID is not co-working meeting'); + } + + return new Response('', { status: 200 }); + } catch (error) { + // output to netlify function log + console.log(error); + return new Response( + JSON.stringify({ + msg: error instanceof Error ? error.message : String(error), + }), + { status: 500 }, + ); + } +}; diff --git a/netlify/functions/zoom-meeting-webhook-handler/slack.ts b/netlify/functions/zoom-meeting-webhook-handler/slack.ts new file mode 100644 index 00000000..d19f7547 --- /dev/null +++ b/netlify/functions/zoom-meeting-webhook-handler/slack.ts @@ -0,0 +1,116 @@ +import { postMessage, updateMessage } from '../_shared/slack'; +import type { Room } from '../_shared/types/room'; + +interface ZoomWebhookRequest { + event: string; + payload: { + object: { + participant: { + user_name: string; + }; + }; + }; +} + +// timestamp: if we have a timestamp, that means we've ended the meeting and are trying to update the message +// otherwise, post a new message + +export async function updateMeetingStatus(room: Room, timestamp?: string) { + const message = { + channel: room.SlackChannelId, + text: timestamp ? room.MessageSessionEnded : room.MessageSessionStarted, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: timestamp + ? room.MessageSessionEnded + : room.MessageSessionStarted, + }, + accessory: { + type: 'button' as const, + text: { + type: 'plain_text' as const, + text: timestamp ? room.ButtonStartNew : room.ButtonJoin, + emoji: true, + }, + value: 'join_meeting', + url: room.ZoomMeetingInviteUrl, + action_id: 'button-action', + style: 'primary' as const, + confirm: { + title: { + type: 'plain_text' as const, + text: room.NoticeTitle, + }, + text: { + type: 'mrkdwn' as const, + text: room.NoticeBody, + }, + confirm: { + type: 'plain_text' as const, + text: room.NoticeConfirm, + }, + deny: { + type: 'plain_text' as const, + text: room.NoticeCancel, + }, + }, + }, + }, + ...(room.ContextBody + ? [ + { + type: 'context' as const, + elements: [ + { + type: 'mrkdwn' as const, + text: room.ContextBody, + }, + ], + }, + ] + : []), + ], + }; + + // These calls never use background mode, so the result is always a Slack API response + const result = timestamp + ? await updateMessage({ ...message, ts: timestamp }) + : await postMessage(message); + + const slackResult = result as { ts?: string }; + + console.log( + `Successfully send message ${slackResult.ts} in conversation ${room.SlackChannelId}`, + ); + + return slackResult; +} + +export async function updateMeetingAttendence( + room: Room, + thread_ts: string, + zoomRequest: ZoomWebhookRequest, +) { + const username = zoomRequest.payload.object.participant.user_name; + const result = await postMessage({ + thread_ts, + text: + zoomRequest.event === 'meeting.participant_joined' + ? `${username} has joined!` + : `${username} has left. We'll miss you!`, + channel: room.SlackChannelId, + }); + + const slackResult = result as { ts?: string }; + + console.log( + `Successfully send message ${slackResult.ts} in conversation ${room.SlackChannelId}`, + ); + + return slackResult; +} From 1b1330c22c65ec239a7b70633d9eaed02f4c2868 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 5 May 2026 14:51:48 -0400 Subject: [PATCH 3/9] refactor: migrate join-coffee and join-slack to TS Functions v2 Replaces the CommonJS handler-style functions with TypeScript Netlify Functions v2 (default-exported (req: Request) => Response). Aligns with the rest of the netlify/functions/ directory now that the webhook functions have landed. The /join-coffee and /join-slack redirects in netlify.toml continue to work unchanged. --- netlify/functions/join-coffee.js | 26 -------------------------- netlify/functions/join-coffee.ts | 18 ++++++++++++++++++ netlify/functions/join-slack.js | 23 ----------------------- netlify/functions/join-slack.ts | 13 +++++++++++++ 4 files changed, 31 insertions(+), 49 deletions(-) delete mode 100644 netlify/functions/join-coffee.js create mode 100644 netlify/functions/join-coffee.ts delete mode 100644 netlify/functions/join-slack.js create mode 100644 netlify/functions/join-slack.ts diff --git a/netlify/functions/join-coffee.js b/netlify/functions/join-coffee.js deleted file mode 100644 index f6f63bcc..00000000 --- a/netlify/functions/join-coffee.js +++ /dev/null @@ -1,26 +0,0 @@ -exports.handler = async function (event, context) { - const { code, day } = event.queryStringParameters; - - if (!code || !(day === 'tuesday' || day === 'thursday')) { - return { - statusCode: 401, - body: 'Invalid request.', - }; - } - - console.log(`Joining ${day}: ${code}`); - - // return { - // statusCode: 200, - // body: '', - // }; - return { - statusCode: 302, - headers: { - Location: - day === 'tuesday' - ? process.env.ZOOM_TUESDAYS - : process.env.ZOOM_THURSDAYS, - }, - }; -}; diff --git a/netlify/functions/join-coffee.ts b/netlify/functions/join-coffee.ts new file mode 100644 index 00000000..1758dace --- /dev/null +++ b/netlify/functions/join-coffee.ts @@ -0,0 +1,18 @@ +import { requireEnv } from './_shared/env'; + +export default async (req: Request) => { + const url = new URL(req.url); + const code = url.searchParams.get('code'); + const day = url.searchParams.get('day'); + + if (!code || !(day === 'tuesday' || day === 'thursday')) { + return new Response('Invalid request.', { status: 401 }); + } + + console.log(`Joining ${day}: ${code}`); + + const target = + day === 'tuesday' ? requireEnv('ZOOM_TUESDAYS') : requireEnv('ZOOM_THURSDAYS'); + + return Response.redirect(target, 302); +}; diff --git a/netlify/functions/join-slack.js b/netlify/functions/join-slack.js deleted file mode 100644 index 1abd2950..00000000 --- a/netlify/functions/join-slack.js +++ /dev/null @@ -1,23 +0,0 @@ -exports.handler = async function (event, context) { - const { code } = event.queryStringParameters; - - if (!code) { - return { - statusCode: 401, - body: 'Invalid request.', - }; - } - - console.log(`Joining slack: ${code}`); - - // return { - // statusCode: 200, - // body: '', - // }; - return { - statusCode: 302, - headers: { - Location: process.env.SLACK_JOIN_LINK, - }, - }; -}; diff --git a/netlify/functions/join-slack.ts b/netlify/functions/join-slack.ts new file mode 100644 index 00000000..590f2eca --- /dev/null +++ b/netlify/functions/join-slack.ts @@ -0,0 +1,13 @@ +import { requireEnv } from './_shared/env'; + +export default async (req: Request) => { + const code = new URL(req.url).searchParams.get('code'); + + if (!code) { + return new Response('Invalid request.', { status: 401 }); + } + + console.log(`Joining slack: ${code}`); + + return Response.redirect(requireEnv('SLACK_JOIN_LINK'), 302); +}; From 6f4416018e61c2a4c7f0a7aca4ad3e0ef81d1142 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 5 May 2026 14:51:55 -0400 Subject: [PATCH 4/9] chore: configure netlify functions and webhook redirects Adds [functions] included_files = ["data/*.json"] so the Zoom handler can bundle the generated rooms.json. Adds /zoom-meeting-webhook-handler, /slack-events, and /slack-interactivity rewrites pointing at the corresponding functions, replacing the routing previously handled by the standalone webhooks site. --- netlify.toml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/netlify.toml b/netlify.toml index 7ac082e9..b79aa119 100644 --- a/netlify.toml +++ b/netlify.toml @@ -17,15 +17,8 @@ autoLaunch = false framework="next" -# [functions] -# node_bundler = "esbuild" - -# [functions.server] -# included_files = ["app/routes/**/*.mdx"] - -# [functions."data-members"] -# external_node_modules = ["shiki"] -# included_files = ["members/**/*.{js,json}"] +[functions] + included_files = ["data/*.json"] [[headers]] for = "/_next/static/*" @@ -218,6 +211,21 @@ to = "/.netlify/functions/join-slack" status = 200 +[[redirects]] + from = "/zoom-meeting-webhook-handler" + to = "/.netlify/functions/zoom-meeting-webhook-handler" + status = 200 + +[[redirects]] + from = "/slack-interactivity" + to = "/.netlify/functions/slack" + status = 200 + +[[redirects]] + from = "/slack-events" + to = "/.netlify/functions/slack" + status = 200 + [[redirects]] from = "/plausible/js/script.js" to = "https://plausible.io/js/script.js" From 1f494eff711c925d26d68153724eb766307ee560 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 5 May 2026 14:52:01 -0400 Subject: [PATCH 5/9] docs: document webhook functions and env vars Adds a Webhooks section to README.md summarizing the HTTP endpoints, scheduled functions, and the rooms prebuild step. Documents the new Slack/Zoom/Airtable env vars in .env.example. Carries over the co-working-bot-ideation design doc from the previous webhooks repo. --- .env.example | 21 ++++++++++ README.md | 21 ++++++++++ docs/co-working-bot-ideation.md | 70 +++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 docs/co-working-bot-ideation.md diff --git a/.env.example b/.env.example index e7f11f58..48c4c18f 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,25 @@ PNPM_FLAGS=--shamefully-hoist # # readonly key for membership base: # MEMBERSHIP_AIRTABLE_API_KEY=token +# +# Webhooks (Slack / Zoom / co-working bot): +# +# These power the Netlify Functions in /netlify/functions/. Most are only needed if you're +# working on those functions locally — production values live in Netlify's env settings. +# +# Slack app credentials (https://api.slack.com/apps): +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_SIGNING_SECRET=... +# SLACK_ANNOUNCEMENTS_CHANNEL=C... +# SLACK_EVENT_ADMIN_CHANNEL=C... +# SLACK_JOIN_LINK=https://join.slack.com/t/... +# +# Zoom app credentials (https://marketplace.zoom.us/develop/apps): +# ZOOM_WEBHOOK_SECRET_TOKEN=... +# ZOOM_WEBHOOK_AUTH=... +# ZOOM_TUESDAYS=https://zoom.us/j/... +# ZOOM_THURSDAYS=https://zoom.us/j/... +# +# Airtable base for co-working rooms (used by scripts/build-rooms.ts and the zoom webhook handler): +# AIRTABLE_COWORKING_BASE=app... # \ No newline at end of file diff --git a/README.md b/README.md index 582b3a7f..3ac48a10 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,27 @@ All of the data points have mock data that is used if the required API key isn't If you'd like to work on a feature that requires an API key, please reach out to a maintainer and we can probably get that going. +## Webhooks + +Netlify Functions in `netlify/functions/` handle webhook events for the Slack and Zoom integrations, plus scheduled event reminders. Shared utilities and types live in `netlify/functions/_shared/`. + +HTTP endpoints (rewrites configured in `netlify.toml`): + +- **`/slack-events`** — Slack Events API. Currently handles `team_join` (welcome message) and `app_home_opened` (publishes the welcome view to a member's App Home). +- **`/slack-interactivity`** — Slack interactivity URL. Shares the same handler as `/slack-events`; required to keep buttons in Slack messages working. +- **`/zoom-meeting-webhook-handler`** — Zoom meeting webhooks. Tracks `meeting.{started,ended,participant_joined,participant_left}` for the co-working room and posts/updates Slack messages and Airtable records. +- **`/join-coffee`** and **`/join-slack`** — short-link redirects to the Tuesday/Thursday Zoom rooms and the Slack invite link. + +Scheduled functions (cron schedules declared inline via `export const config`): + +- **`event-reminders-daily`** — `0 12 * * *` (12pm UTC daily). Posts that day's events to the announcements channel; skips Mondays since the weekly reminder runs that day. +- **`event-reminders-hourly`** — `50 * * * *` (50 minutes past every hour). Posts upcoming events starting in the next hour. +- **`event-reminders-weekly`** — `0 12 * * 1` (Monday 12pm UTC). Posts the week's events. + +The `scripts/build-rooms.ts` prebuild step pulls co-working room records from Airtable into `data/rooms.json`, which is bundled into the Zoom function via `included_files` in `netlify.toml`. + +See `docs/co-working-bot-ideation.md` for an unbuilt design exploring deeper Slack Calls API integration. + ## Adding content ### Resources diff --git a/docs/co-working-bot-ideation.md b/docs/co-working-bot-ideation.md new file mode 100644 index 00000000..022ee2df --- /dev/null +++ b/docs/co-working-bot-ideation.md @@ -0,0 +1,70 @@ +# Co-working-room Bot Ideation + +## Using Zoom and Slack APIs + +### Variables + +- **registrant_id** - The ID programmatically generated by Zoom per join request, changes each session +- **slack_id** - The ID generated for a user's Slack profile, remains static +- **slack_call_id** - The ID generated for Slack's call widget per Zoom session, changes each session (stored in DB with **zoom_meeting_id**) +- **zoom_meeting_id** - The ID stored in DB for the life of the Zoom meeting, session-based (stored in DB with **slack_call_id**) + +#### **Trigger:** Slack --> A user clicks the **Start a New Session!** or **Join** button in the `#co-working-room` widget + +1. Check to see if there's an active **zoom_meeting_id** in the database + 1a. Yes - Retrieve the **zoom_meeting_id** and **slack_call_id** from the database, then proceed to **Step 2** + 1b. No - Create the Zoom meeting + 1c. Call Slack API ([`calls.add`][]) to reflect an active session in the widget + 1d. Store the **zoom_meeting_id** and **slack_call_id** in the database for the duration of the meeting's lifecycle +2. Add a new **registrant_id** via Zoom API - this returns a meeting join URL +3. Redirect the user's browser to the generated meeting join URL to launch the Zoom session + +#### **Trigger:** Zoom webhooks --> A user joins the meeting + +1. Zoom [`meeting.participant_joined`][] webhook fires +2. Get participant info from the [webhook payload][`meeting.participant_joined`] or the [Get a meeting registrant][] endpoint +3. Call Slack API ([`calls.participants.add`][]) to add the user to the widget + +#### **Trigger:** Zoom webhooks --> A user leaves the meeting + +1. Zoom [`meeting.participant_left`][] webhook fires +2. Get participant info from the [webhook payload][`meeting.participant_left`] or the [Get a meeting registrant][] endpoint +3. Call Slack API ([`calls.participants.remove`][]) to remove the user from the widget + +#### **Trigger:** Zoom webhooks --> The last user leaves the meeting + +1. Zoom [`meeting.ended`][] webhook fires +2. Set **zoom_meeting_id** and **slack_call_id** to inactive in the database +3. Call Slack API ([`calls.end`][]) to reflect an ended session in the widget +4. Post a message to `#co-working-room` with the **Start a New Session!** button + +### Connecting the APIs + +- Add the **slack_id** to a field in Zoom's [Add a meeting registrant][] API + - Either use the `first_name` field or `custom_questions` array + +## Important Links + +- [Zoom Developer APIs][Zoom API] +- [Slack Calls API][Slack Calls API] +- [Eddie's Proof of Concept][Eddie's POC] + +[Zoom API]: https://developers.zoom.us/docs/api/ +[Zoom Endpoints]: https://developers.zoom.us/docs/api/meetings/ +[Zoom Webhooks]: https://developers.zoom.us/docs/api/meetings/ +[Add a meeting registrant]: https://developers.zoom.us/docs/api/meetings/#tag/meetings/post/meetings/%7BmeetingId%7D/registrants +[Get a meeting registrant]: https://developers.zoom.us/docs/api/meetings/#tag/meetings/get/meetings/%7BmeetingId%7D/registrants/%7BregistrantId%7D +[Delete a meeting registrant]: https://developers.zoom.us/docs/api/meetings/#tag/meetings/delete/meetings/%7BmeetingId%7D/registrants/%7BregistrantId%7D +[`meeting.participant_joined`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.participant_joined +[`meeting.participant_left`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.participant_left +[`meeting.created`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.created +[`meeting.deleted`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.deleted +[`meeting.started`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.started +[`meeting.ended`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.ended +[Slack Calls API]: https://api.slack.com/apis/calls +[`calls.add`]: https://api.slack.com/methods/calls.add +[`calls.end`]: https://api.slack.com/methods/calls.end +[`calls.participants.add`]: https://api.slack.com/methods/calls.participants.add +[`calls.participants.remove`]: https://api.slack.com/methods/calls.participants.remove +[Eddie's POC]: https://github.com/ebanner/vc-co-working-room-app/tree/main +[GitHub discussion]: https://github.com/orgs/Virtual-Coffee/discussions/1312 From 8e96823760141b1804b7c1f1f528c5d87a2e5764 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 5 May 2026 15:11:16 -0400 Subject: [PATCH 6/9] chore: drop unbuilt bot doc and add pretypecheck step Remove the co-working-bot ideation doc (and its README link) since the design was never built. Add a pretypecheck script so tsc finds data/rooms.json on a clean clone. --- README.md | 2 - docs/co-working-bot-ideation.md | 70 --------------------------------- package.json | 1 + 3 files changed, 1 insertion(+), 72 deletions(-) delete mode 100644 docs/co-working-bot-ideation.md diff --git a/README.md b/README.md index 3ac48a10..23244027 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,6 @@ Scheduled functions (cron schedules declared inline via `export const config`): The `scripts/build-rooms.ts` prebuild step pulls co-working room records from Airtable into `data/rooms.json`, which is bundled into the Zoom function via `included_files` in `netlify.toml`. -See `docs/co-working-bot-ideation.md` for an unbuilt design exploring deeper Slack Calls API integration. - ## Adding content ### Resources diff --git a/docs/co-working-bot-ideation.md b/docs/co-working-bot-ideation.md deleted file mode 100644 index 022ee2df..00000000 --- a/docs/co-working-bot-ideation.md +++ /dev/null @@ -1,70 +0,0 @@ -# Co-working-room Bot Ideation - -## Using Zoom and Slack APIs - -### Variables - -- **registrant_id** - The ID programmatically generated by Zoom per join request, changes each session -- **slack_id** - The ID generated for a user's Slack profile, remains static -- **slack_call_id** - The ID generated for Slack's call widget per Zoom session, changes each session (stored in DB with **zoom_meeting_id**) -- **zoom_meeting_id** - The ID stored in DB for the life of the Zoom meeting, session-based (stored in DB with **slack_call_id**) - -#### **Trigger:** Slack --> A user clicks the **Start a New Session!** or **Join** button in the `#co-working-room` widget - -1. Check to see if there's an active **zoom_meeting_id** in the database - 1a. Yes - Retrieve the **zoom_meeting_id** and **slack_call_id** from the database, then proceed to **Step 2** - 1b. No - Create the Zoom meeting - 1c. Call Slack API ([`calls.add`][]) to reflect an active session in the widget - 1d. Store the **zoom_meeting_id** and **slack_call_id** in the database for the duration of the meeting's lifecycle -2. Add a new **registrant_id** via Zoom API - this returns a meeting join URL -3. Redirect the user's browser to the generated meeting join URL to launch the Zoom session - -#### **Trigger:** Zoom webhooks --> A user joins the meeting - -1. Zoom [`meeting.participant_joined`][] webhook fires -2. Get participant info from the [webhook payload][`meeting.participant_joined`] or the [Get a meeting registrant][] endpoint -3. Call Slack API ([`calls.participants.add`][]) to add the user to the widget - -#### **Trigger:** Zoom webhooks --> A user leaves the meeting - -1. Zoom [`meeting.participant_left`][] webhook fires -2. Get participant info from the [webhook payload][`meeting.participant_left`] or the [Get a meeting registrant][] endpoint -3. Call Slack API ([`calls.participants.remove`][]) to remove the user from the widget - -#### **Trigger:** Zoom webhooks --> The last user leaves the meeting - -1. Zoom [`meeting.ended`][] webhook fires -2. Set **zoom_meeting_id** and **slack_call_id** to inactive in the database -3. Call Slack API ([`calls.end`][]) to reflect an ended session in the widget -4. Post a message to `#co-working-room` with the **Start a New Session!** button - -### Connecting the APIs - -- Add the **slack_id** to a field in Zoom's [Add a meeting registrant][] API - - Either use the `first_name` field or `custom_questions` array - -## Important Links - -- [Zoom Developer APIs][Zoom API] -- [Slack Calls API][Slack Calls API] -- [Eddie's Proof of Concept][Eddie's POC] - -[Zoom API]: https://developers.zoom.us/docs/api/ -[Zoom Endpoints]: https://developers.zoom.us/docs/api/meetings/ -[Zoom Webhooks]: https://developers.zoom.us/docs/api/meetings/ -[Add a meeting registrant]: https://developers.zoom.us/docs/api/meetings/#tag/meetings/post/meetings/%7BmeetingId%7D/registrants -[Get a meeting registrant]: https://developers.zoom.us/docs/api/meetings/#tag/meetings/get/meetings/%7BmeetingId%7D/registrants/%7BregistrantId%7D -[Delete a meeting registrant]: https://developers.zoom.us/docs/api/meetings/#tag/meetings/delete/meetings/%7BmeetingId%7D/registrants/%7BregistrantId%7D -[`meeting.participant_joined`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.participant_joined -[`meeting.participant_left`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.participant_left -[`meeting.created`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.created -[`meeting.deleted`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.deleted -[`meeting.started`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.started -[`meeting.ended`]: https://developers.zoom.us/docs/api/meetings/events/#tag/meeting/postmeeting.ended -[Slack Calls API]: https://api.slack.com/apis/calls -[`calls.add`]: https://api.slack.com/methods/calls.add -[`calls.end`]: https://api.slack.com/methods/calls.end -[`calls.participants.add`]: https://api.slack.com/methods/calls.participants.add -[`calls.participants.remove`]: https://api.slack.com/methods/calls.participants.remove -[Eddie's POC]: https://github.com/ebanner/vc-co-working-room-app/tree/main -[GitHub discussion]: https://github.com/orgs/Virtual-Coffee/discussions/1312 diff --git a/package.json b/package.json index ba32d4ed..1c80fcde 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build": "next build", "start": "next start", "prebuild": "pnpm build-member-files && pnpm build-rooms", + "pretypecheck": "pnpm build-rooms", "typecheck": "tsc --noEmit", "watch": "npm-watch", "dev": "pnpm build-member-files && concurrently \"pnpm watch\" \"pnpm local-dev\"" From 7b4d7e8109e5eb65d09c17bd21a8c13b24e4e0e1 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Sat, 16 May 2026 13:32:35 -0400 Subject: [PATCH 7/9] fix: spelling Signed-off-by: Joe Karow <58997957+JoeKarow@users.noreply.github.com> --- netlify/functions/zoom-meeting-webhook-handler/index.ts | 4 ++-- netlify/functions/zoom-meeting-webhook-handler/slack.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netlify/functions/zoom-meeting-webhook-handler/index.ts b/netlify/functions/zoom-meeting-webhook-handler/index.ts index 0e4c44f5..5a196891 100644 --- a/netlify/functions/zoom-meeting-webhook-handler/index.ts +++ b/netlify/functions/zoom-meeting-webhook-handler/index.ts @@ -1,5 +1,5 @@ import Airtable from 'airtable'; -import { updateMeetingStatus, updateMeetingAttendence } from './slack'; +import { updateMeetingStatus, updateMeetingAttendance } from './slack'; import { findRoomInstance } from './airtable'; import { requireEnv } from '../_shared/env'; import { verifyZoomSignature, hmacSha256Hex } from '../_shared/verify'; @@ -80,7 +80,7 @@ export default async (req: Request) => { // create room event record console.log(`found room instance ${roomInstance.getId()}`); - await updateMeetingAttendence( + await updateMeetingAttendance( room, roomInstance.get('slack_thread_timestamp') as string, request, diff --git a/netlify/functions/zoom-meeting-webhook-handler/slack.ts b/netlify/functions/zoom-meeting-webhook-handler/slack.ts index d19f7547..33ff8763 100644 --- a/netlify/functions/zoom-meeting-webhook-handler/slack.ts +++ b/netlify/functions/zoom-meeting-webhook-handler/slack.ts @@ -91,7 +91,7 @@ export async function updateMeetingStatus(room: Room, timestamp?: string) { return slackResult; } -export async function updateMeetingAttendence( +export async function updateMeetingAttendance( room: Room, thread_ts: string, zoomRequest: ZoomWebhookRequest, From 564133354c417a58cbb6f196f9d92e5c08903ce5 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Sat, 16 May 2026 13:47:00 -0400 Subject: [PATCH 8/9] fix: lock file Signed-off-by: Joe Karow <58997957+JoeKarow@users.noreply.github.com> --- pnpm-workspace.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c5417db..704836d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ onlyBuiltDependencies: + - '@parcel/watcher' - esbuild - netlify-cli + - sharp + - unix-dgram + - unrs-resolver From f704c90f93c7f7e9a58769c2276de27310b3330f Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Sat, 16 May 2026 13:48:26 -0400 Subject: [PATCH 9/9] fix: lock file Signed-off-by: Joe Karow <58997957+JoeKarow@users.noreply.github.com> --- pnpm-lock.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21ec9e23..dc41e39b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8532,14 +8532,14 @@ snapshots: '@slack/logger@4.0.1': dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@slack/oauth@3.0.5': dependencies: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.2 '@types/jsonwebtoken': 9.0.10 - '@types/node': 24.12.2 + '@types/node': 24.12.4 jsonwebtoken: 9.0.2 transitivePeerDependencies: - debug @@ -8548,7 +8548,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.2 - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/ws': 8.18.1 eventemitter3: 5.0.4 ws: 8.18.3 @@ -8563,7 +8563,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/types': 2.21.0 - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/retry': 0.12.0 axios: 1.16.0 eventemitter3: 5.0.4 @@ -8652,7 +8652,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/leaflet.markercluster@1.5.6': dependencies: @@ -8720,7 +8720,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.4 '@types/yauzl@2.10.3': dependencies: