├── scripts ├── .gitignore ├── requirements.txt └── zstory.py ├── public ├── favicon.ico ├── friedolin_woff │ ├── Friedolin.woff │ ├── example.css │ └── example.html ├── auth-success.html ├── auth-error.html ├── support.html ├── phone.html ├── auth-details.html ├── privacy.html ├── index.html ├── tos.html └── auth-start.html ├── docs ├── img │ ├── slack-call.png │ ├── zoom-users.png │ └── slack-call-update.png ├── add-zoom-licenses.md ├── certify.md ├── 2021-04-02_account_transition_email.md └── runbook.md ├── migration.md ├── env.js ├── .gitignore ├── prisma ├── migrations │ ├── 20230614152527_call_id │ │ └── migration.sql │ ├── 20220112185013_host_key_is_optional │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20220112182425_fix_required_constraint_on_scheduling_link_creator_slack_id │ │ └── migration.sql │ ├── 20230614152406_add_custom_logs │ │ └── migration.sql │ ├── 20220112185406_relax_required_fields_in_meeting_model_while_migrating_from_airtable │ │ └── migration.sql │ ├── 20220112184852_move_json_fields_to_strings_while_importing_from_airtable │ │ └── migration.sql │ ├── 20220112184558_attempt_to_fix_invalid_reference_from_meeting_to_host │ │ └── migration.sql │ ├── 20220112135901_scheduling_link_id_opt │ │ └── migration.sql │ ├── 20220112182619_fix_required_constraint_on_scheduling_link_authed_account_id │ │ └── migration.sql │ ├── 20220112180006_fix_ids_for_a_couple_models │ │ └── migration.sql │ └── 20220112132642_first │ │ └── migration.sql └── schema.prisma ├── api ├── endpoints │ ├── signup.js │ ├── stats.js │ ├── slack │ │ ├── slash-z-rooms.js │ │ ├── index.js │ │ ├── events.js │ │ ├── host-code.js │ │ └── slash-z.js │ ├── new-schedule-link.js │ ├── schedule-link.js │ ├── slack-auth.js │ └── zoom.js ├── time-hash.js ├── string-to-color.js ├── record-error.js ├── channel-is-forbidden.js ├── user-is-restricted.js ├── send-recording-notification.js ├── send-host-key.js ├── ensure-slack-authenticated.js ├── ensure-zoom-authenticated.js ├── get-scheduled-meetings.js ├── zoom-meeting-to-recording.js ├── is-public-slack-channel.js ├── close-stale-calls.js ├── state.js ├── get-public-meetings.js ├── update-slack-call-participant-list.js ├── NOTES.md ├── hack-night.js ├── find-or-create-meeting.js ├── close-zoom-call.js ├── zoom-client.js ├── transcript.js ├── open-zoom-meeting.js ├── slack-app-home-opened.js └── prisma.js ├── prettier.config.json ├── isprod.js ├── template.env ├── bugsnag.js ├── docker-compose.yaml ├── metrics.js ├── Dockerfile ├── jobs ├── index.js ├── batch-upload.js └── remove-table.js ├── package.json ├── manifest.yml ├── race.test.js ├── server.js ├── routes.js ├── README.md ├── lib └── transcript.yml └── dashboards └── grafana.json /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .env 3 | venv 4 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg 2 | slack_sdk -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackclub/slash-z/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /docs/img/slack-call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackclub/slash-z/HEAD/docs/img/slack-call.png -------------------------------------------------------------------------------- /docs/img/zoom-users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackclub/slash-z/HEAD/docs/img/zoom-users.png -------------------------------------------------------------------------------- /migration.md: -------------------------------------------------------------------------------- 1 | # Migration todo list: 2 | - [ ] Google calendar integration 3 | - [ ] Recording meetings -------------------------------------------------------------------------------- /docs/img/slack-call-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackclub/slash-z/HEAD/docs/img/slack-call-update.png -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import isProd from './isprod.js' 3 | if (!isProd) { 4 | dotenv.config() 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env_prod 2 | .vscode 3 | .vercel 4 | node_modules 5 | *.env 6 | .replit 7 | package-lock.json 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /public/friedolin_woff/Friedolin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackclub/slash-z/HEAD/public/friedolin_woff/Friedolin.woff -------------------------------------------------------------------------------- /prisma/migrations/20230614152527_call_id/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CustomLogs" ADD COLUMN "zoomCallId" TEXT; 3 | -------------------------------------------------------------------------------- /api/endpoints/signup.js: -------------------------------------------------------------------------------- 1 | export default (req, res) => { 2 | res.redirect('http://workspace.google.com/marketplace/app/appname/299631665103') 3 | } -------------------------------------------------------------------------------- /prisma/migrations/20220112185013_host_key_is_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Meeting" ALTER COLUMN "hostKey" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /prettier.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "arrowParens": "avoid", 5 | "printWidth": 80, 6 | "semi": false 7 | } -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/migrations/20220112182425_fix_required_constraint_on_scheduling_link_creator_slack_id/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "SchedulingLink" ALTER COLUMN "creatorSlackID" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /isprod.js: -------------------------------------------------------------------------------- 1 | // detects whether or not we're on staging 2 | // returns true if NODE_ENV === "production" otherwise false 3 | const isProd = process.env.NODE_ENV === "production"; 4 | export default isProd; 5 | -------------------------------------------------------------------------------- /api/time-hash.js: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto'; 2 | 3 | export function currentTimeHash () { 4 | return createHash('sha256').update(new Date().getHours() + '' + (process.env.JOIN_URL_SALT ?? '')).digest('hex'); 5 | } -------------------------------------------------------------------------------- /prisma/migrations/20230614152406_add_custom_logs/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "CustomLogs" ( 3 | "id" TEXT NOT NULL, 4 | "text" TEXT, 5 | 6 | CONSTRAINT "CustomLogs_pkey" PRIMARY KEY ("id") 7 | ); 8 | -------------------------------------------------------------------------------- /public/auth-success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 |

Authentication successful! You can now close this window

10 | 11 | -------------------------------------------------------------------------------- /template.env: -------------------------------------------------------------------------------- 1 | AIRTABLE_API_KEY=REPLACEME 2 | AIRTABLE_BASE=REPLACEME 3 | AIRBRIDGE_API_KEY=REPLACEME 4 | SLACK_BOT_USER_OAUTH_ACCESS_TOKEN=REPLACEME 5 | SLACK_SIGNING_SECRET=REPLACEME # optional 6 | ZOOM_VERIFICATION_TOKEN=REPLACEME 7 | BUGSNAG_API_KEY=REPLACEME # optional 8 | -------------------------------------------------------------------------------- /public/auth-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Sign in error

6 |

Something went wrong in the sign up process. Please try again or send us an email at slash-z@hackclub.com. Included screenshots help!

7 | 8 | -------------------------------------------------------------------------------- /prisma/migrations/20220112185406_relax_required_fields_in_meeting_model_while_migrating_from_airtable/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Meeting" ALTER COLUMN "creatorSlackID" DROP NOT NULL, 3 | ALTER COLUMN "joinURL" DROP NOT NULL, 4 | ALTER COLUMN "hostJoinURL" DROP NOT NULL; 5 | -------------------------------------------------------------------------------- /public/friedolin_woff/example.css: -------------------------------------------------------------------------------- 1 | /* #### Generated By: http://www.fontget.com #### */ 2 | 3 | @font-face { 4 | font-family: 'Friedolin'; 5 | font-style: normal; 6 | font-weight: normal; 7 | src: local('Friedolin'), url('friedolin_woff/Friedolin.woff') format('woff'); 8 | } 9 | -------------------------------------------------------------------------------- /prisma/migrations/20220112184852_move_json_fields_to_strings_while_importing_from_airtable/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Meeting" ALTER COLUMN "rawWebhookEvents" SET DATA TYPE TEXT, 3 | ALTER COLUMN "rawData" SET DATA TYPE TEXT; 4 | 5 | -- AlterTable 6 | ALTER TABLE "WebhookEvent" ALTER COLUMN "rawData" SET DATA TYPE TEXT; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20220112184558_attempt_to_fix_invalid_reference_from_meeting_to_host/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Meeting" DROP CONSTRAINT "Meeting_hostID_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_hostID_fkey" FOREIGN KEY ("hostID") REFERENCES "Host"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /api/endpoints/stats.js: -------------------------------------------------------------------------------- 1 | import { getTotalHosts, getOpenHosts, getCurrentlyActiveUsers } from "../state.js"; 2 | 3 | export default async (req, res) => { 4 | const data = { 5 | hosts: { 6 | total: await getTotalHosts(), 7 | open: await getOpenHosts(), 8 | active_users: await getCurrentlyActiveUsers() 9 | } 10 | } 11 | return res.send(data) 12 | } 13 | -------------------------------------------------------------------------------- /bugsnag.js: -------------------------------------------------------------------------------- 1 | import Bugsnag from '@bugsnag/js' 2 | import BugsnagPluginExpress from '@bugsnag/plugin-express' 3 | 4 | let started = false 5 | 6 | export default () => { 7 | if (!started) { 8 | Bugsnag.start({ 9 | apiKey: process.env.BUGSNAG_API_KEY, 10 | plugins: [BugsnagPluginExpress], 11 | }) 12 | started = true 13 | } 14 | return Bugsnag.getPlugin('express') 15 | } -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | slash-z: 5 | build: . 6 | container_name: slash-z 7 | environment: 8 | - NODE_OPTIONS=--max-old-space-size=492 # ~0.5GB 9 | env_file: 10 | - .env 11 | ports: 12 | - "3000" 13 | restart: unless-stopped 14 | deploy: 15 | resources: 16 | limits: 17 | memory: 512M 18 | 19 | -------------------------------------------------------------------------------- /metrics.js: -------------------------------------------------------------------------------- 1 | import StatsD from 'node-statsd' 2 | 3 | const environment = process.env.NODE_ENV 4 | const graphite = process.env.GRAPHITE_HOST 5 | 6 | if (graphite == null) { 7 | throw new Error('Graphite host not configured!') 8 | } 9 | 10 | const options = { 11 | host: graphite, 12 | port: 8125, 13 | prefix: `${environment}.slashz.`, 14 | } 15 | 16 | const metrics = new StatsD(options) 17 | 18 | export default metrics; 19 | -------------------------------------------------------------------------------- /prisma/migrations/20220112135901_scheduling_link_id_opt/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Meeting" DROP CONSTRAINT "Meeting_schedulingLinkId_fkey"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Meeting" ALTER COLUMN "schedulingLinkId" DROP NOT NULL; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_schedulingLinkId_fkey" FOREIGN KEY ("schedulingLinkId") REFERENCES "SchedulingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE; 9 | -------------------------------------------------------------------------------- /api/string-to-color.js: -------------------------------------------------------------------------------- 1 | // Taken from https://jsfiddle.net/sUK45/ 2 | // https://stackoverflow.com/a/16348977 3 | 4 | export default (str) => { 5 | let hash = 0; 6 | for (let i = 0; i < str.length; i++) { 7 | hash = str.charCodeAt(i) + ((hash << 5) - hash) 8 | } 9 | let colour = '' 10 | for (var i = 0; i < 3; i++) { 11 | var value = (hash >> (i * 8)) & 0xFF 12 | colour += ('00' + value.toString(16)).substr(-2) 13 | } 14 | return colour 15 | } 16 | -------------------------------------------------------------------------------- /public/friedolin_woff/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 |

Generated from: http://www.fontget.com


12 |

AaBbCcDdEeFfGgHhŞşIıİi Example

13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /prisma/migrations/20220112182619_fix_required_constraint_on_scheduling_link_authed_account_id/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "SchedulingLink" DROP CONSTRAINT "SchedulingLink_authedAccountID_fkey"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "SchedulingLink" ALTER COLUMN "authedAccountID" DROP NOT NULL; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "SchedulingLink" ADD CONSTRAINT "SchedulingLink_authedAccountID_fkey" FOREIGN KEY ("authedAccountID") REFERENCES "AuthedAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE; 9 | -------------------------------------------------------------------------------- /api/record-error.js: -------------------------------------------------------------------------------- 1 | import Prisma from "./prisma.js" 2 | import isProd from "../isprod.js" 3 | 4 | export default (err) => { 5 | const errorFields = { 6 | timestamp: new Date(Date.now()), 7 | text: `${err.name} ${err.message}`, 8 | stackTrace: err.stack, 9 | production: isProd 10 | } 11 | if (err.zoomHostID) { 12 | errorFields.hostZoomID = err.zoomHostID 13 | } 14 | if (err.zoomMeetingID) { 15 | errorFields.meetingId = err.zoomMeetingID 16 | } 17 | 18 | Prisma.create('errorLog', errorFields) 19 | } -------------------------------------------------------------------------------- /api/channel-is-forbidden.js: -------------------------------------------------------------------------------- 1 | /** 2 | * check if the slack channel is forbidden 3 | * @function 4 | * @param {string} channelID - The ID of the slack channel 5 | * @returns {boolean} 6 | */ 7 | export default (channelID) => { 8 | const forbiddenChannels = [ 9 | 'C74HZS5A5', // #lobby 10 | 'C75M7C0SY', // # welcome 11 | 'C01504DCLVD', // #scrapbook 12 | 'C0M8PUPU6', // #ship 13 | 'C0EA9S0A0', // #code 14 | 'C0C78SG9L', // #hq 15 | 'C039PAG1AV7', // #cave-entrance-start 16 | ] 17 | return forbiddenChannels.includes(channelID) 18 | } 19 | -------------------------------------------------------------------------------- /api/user-is-restricted.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | /** 4 | * Returns true if a user is restricted 5 | * @function 6 | * @param {string} userID - The slack user ID 7 | * @returns {Promise} 8 | */ 9 | export default async (userID) => { 10 | const userInfo = await fetch(`https://slack.com/api/users.info?user=${userID}`, { 11 | headers: { 12 | Authorization: `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}` 13 | } 14 | }).then(r => r.json()) 15 | const { is_restricted, is_ultra_restricted } = userInfo.user 16 | return is_restricted || is_ultra_restricted 17 | } -------------------------------------------------------------------------------- /api/send-recording-notification.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | // THIS IS A WIP 4 | export default async (meeting) => { 5 | if (!meeting.fields['channel']) { 6 | // this isn't a message to post in the slack 7 | return null 8 | } 9 | const slackPost = await fetch('https://slack.com/api/chat.postMessage', { 10 | method: 'post', 11 | headers: { 12 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 13 | 'Content-Type': 'application/json' 14 | }, 15 | body: JSON.stringify({ 16 | channel: '', 17 | thread_ts: '', 18 | text: '', 19 | }) 20 | }) 21 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Alpine-based Node.js v22 2 | FROM node:22-alpine 3 | 4 | # Install OpenSSL and other necessary dependencies 5 | RUN apk add --no-cache openssl 6 | 7 | RUN apk add --no-cache ca-certificates 8 | 9 | # Set working directory inside the container 10 | WORKDIR /app 11 | 12 | # Copy package.json and package-lock.json first (for caching layer efficiency) 13 | COPY package*.json ./ 14 | 15 | # Install dependencies 16 | RUN npm install --production 17 | 18 | # Copy the rest of the application files 19 | COPY . . 20 | 21 | # Expose the application port (change this if needed) 22 | EXPOSE 3000 23 | 24 | # Define the default command 25 | CMD ["npm", "start"] 26 | -------------------------------------------------------------------------------- /jobs/index.js: -------------------------------------------------------------------------------- 1 | import closeStaleCalls from '../api/close-stale-calls.js' 2 | import isProd from '../isprod.js' 3 | 4 | 5 | // this file should run in production-- it queues a bunch of tasks that are meant to run in production 6 | 7 | // keep in mind heroku restarts daily, so tasks may only need to be run once per server run 8 | 9 | // we'll queue it up for a couple minutes later in case we have multiple rebuilds in a row 10 | if (isProd) { 11 | console.log('Queueing jobs...') 12 | 13 | setTimeout(() => { 14 | setInterval(closeStaleCalls, 1000 * 120) // every 2 minutes 15 | }, 1000 * 60) // after 1 minute in milliseconds 16 | } else { 17 | closeStaleCalls() 18 | setTimeout(() => { 19 | setInterval(closeStaleCalls, 1000 * 120) // every 2 minutes 20 | }, 1000 * 60) // after 1 minute in milliseconds 21 | } 22 | -------------------------------------------------------------------------------- /api/send-host-key.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | const postMessage = async ({channel, text}) => { 4 | return await fetch('https://slack.com/api/chat.postMessage', { 5 | method: 'post', 6 | headers: { 7 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 8 | 'Content-Type': 'application/json' 9 | }, 10 | body: JSON.stringify({channel, text}) 11 | }).then(r => r.json()) 12 | } 13 | 14 | export default async ({creatorSlackID, hostKey}) => { 15 | console.log('Posting the Slack DM', {creatorSlackID, hostKey}) 16 | // while debugging, only run for tester 17 | const slackDMs = [] 18 | // slackDMs.push(await postMessage({ 19 | // channel: creatorSlackID, 20 | // text: `_*${hostKey}*_ is the host key for the call you just opened.` 21 | // })); 22 | 23 | return slackDMs; 24 | } 25 | -------------------------------------------------------------------------------- /api/ensure-slack-authenticated.js: -------------------------------------------------------------------------------- 1 | // this is a helper method to make sure the slack request we get is authentic 2 | import crypto from 'crypto' 3 | 4 | export default async (req, res, callback) => { 5 | const secret = process.env.SLACK_SIGNING_SECRET 6 | // if there is no signing secret in the config, just skip 7 | if (!secret) { 8 | return callback() 9 | } 10 | const timestamp = req.header('X-Slack-Request-Timestamp') 11 | const body = req.body 12 | const sigBasestring = `v0:${timestamp}:${body}` 13 | const mySig = `v0=${crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex')}` 14 | console.log({ 15 | slackSig: req.header('X-Slack-Signature'), 16 | mySig 17 | }) 18 | if (req.header('X-Slack-Signature') == mySig) { 19 | callback() 20 | } else { 21 | res.status(403).send('Missing/invalid Slack verification token') 22 | } 23 | } -------------------------------------------------------------------------------- /api/ensure-zoom-authenticated.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import metrics from "../metrics.js"; 3 | // this is a helper method to make sure the zoom request we get is authentic 4 | export default async (req, res, callback) => { 5 | if (isZoomAuthenticRequest(req)) { 6 | await callback(); 7 | } else { 8 | metrics.increment("errors.zoom_webhook_auth_failed", 1); 9 | res.status(403).send('Unauthorized sender'); 10 | } 11 | } 12 | 13 | function isZoomAuthenticRequest(req) { 14 | const message = `v0:${req.headers['x-zm-request-timestamp']}:${JSON.stringify(req.body)}`; 15 | const hashForVerify = crypto.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN).update(message).digest('hex'); 16 | const signature = `v0=${hashForVerify}`; 17 | 18 | if (req.headers['x-zm-signature'] === signature) { 19 | return true; 20 | } 21 | return false; 22 | } 23 | -------------------------------------------------------------------------------- /api/get-scheduled-meetings.js: -------------------------------------------------------------------------------- 1 | import prisma from "./prisma.js" 2 | 3 | /** 4 | * Get scheduled meetings 5 | * @function 6 | * @param {string} user - The slack ID of the user 7 | * @returns {Promise} 8 | */ 9 | export default async function(user) { 10 | const linksWhere = { 11 | creatorSlackID: user, 12 | meetings: { 13 | some: { 14 | endedAt: { 15 | equals: null, 16 | } 17 | } 18 | } 19 | } 20 | const links = await prisma.get('schedulingLink', {where: linksWhere}) 21 | 22 | const meetings = await Promise.all(links.map(async link => { 23 | 24 | let meetingWhere = { 25 | endedAt: {equals: null}, 26 | hostKey: {not: null}, 27 | schedulingLinkId: link.id 28 | } 29 | const meeting = await prisma.find('meeting', {where: meetingWhere}) 30 | return { meeting, link } 31 | })) 32 | 33 | return meetings 34 | } -------------------------------------------------------------------------------- /api/endpoints/slack/slash-z-rooms.js: -------------------------------------------------------------------------------- 1 | import getPublicMeetings from "../../get-public-meetings.js" 2 | import transcript from "../../transcript.js" 3 | import fetch from 'node-fetch' 4 | 5 | export default async (req, res) => { 6 | const meetings = await getPublicMeetings() 7 | 8 | let messageText = '' 9 | if (meetings.length > 1) { 10 | messageText = transcript('publicMeetings.multiple', {meetings}) 11 | } else if (meetings.length > 0) { 12 | messageText = transcript('publicMeetings.single', {meeting: meetings[0]}) 13 | } else { 14 | messageText = transcript('publicMeetings.none') 15 | } 16 | 17 | await fetch(req.body.response_url, { 18 | method: 'post', 19 | headers: { 20 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 21 | 'Content-Type': 'application/json' 22 | }, 23 | body: JSON.stringify({ 24 | response_type: 'ephemeral', 25 | text: messageText, 26 | }) 27 | }) 28 | } -------------------------------------------------------------------------------- /api/endpoints/slack/index.js: -------------------------------------------------------------------------------- 1 | import ensureSlackAuthenticated from "../../ensure-slack-authenticated.js" 2 | import slashZ from './slash-z.js' 3 | import slashZRooms from './slash-z-rooms.js' 4 | import hostCode from "./host-code.js" 5 | import isProd from "../../../isprod.js" 6 | 7 | export default async (req, res) => { 8 | return await ensureSlackAuthenticated(req, res, async () => { 9 | 10 | // Acknowledge we got the message so Slack doesn't show an error to the user 11 | res.status(200).send() 12 | 13 | switch(req.body.command) { 14 | case isProd ? '/z' : '/test-z': 15 | await slashZ(req, res) 16 | break 17 | case isProd ? '/z-rooms' : '/test-z-rooms': 18 | await slashZRooms(req, res) 19 | break 20 | case isProd ? 'z-code' : '/test-z-code': 21 | await hostCode(req, res) 22 | break 23 | default: 24 | throw new Error(`Unsupported slash command: '${req.body.command}'`) 25 | } 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slash-z", 3 | "version": "1.0.0", 4 | "main": "server.js", 5 | "repository": "git@github.com:hackclub/slash-z.git", 6 | "author": "Max Wofford ", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "dev": "NODE_ENV=development nodemon", 11 | "start": "npx prisma generate && node server.js", 12 | "fmt": "npx prettier", 13 | "test": "jest" 14 | }, 15 | "dependencies": { 16 | "@bugsnag/js": "^7.11.0", 17 | "@bugsnag/plugin-express": "^7.11.0", 18 | "@prisma/client": "^5.9.1", 19 | "airtable-plus": "^1.0.4", 20 | "bottleneck": "^2.19.5", 21 | "dotenv": "^8.2.0", 22 | "express": "^4.17.1", 23 | "js-yaml": "^4.0.0", 24 | "jsonwebtoken": "^8.5.1", 25 | "md5": "^2.3.0", 26 | "node-fetch": "^2.6.1", 27 | "node-statsd": "^0.1.1", 28 | "nodemon": "^2.0.7", 29 | "prisma": "^5.9.1", 30 | "response-time": "^2.3.2", 31 | "zoomus": "^0.1.4" 32 | }, 33 | "devDependencies": { 34 | "jest": "^29.6.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /jobs/batch-upload.js: -------------------------------------------------------------------------------- 1 | // msw: I'm running into a similar issue to what's described here: https://github.com/prisma/prisma/issues/7644 2 | // I've tried batching uploads 3 | 4 | import prisma from '../api/prisma.js' 5 | 6 | const batch = (arr, size) => { 7 | return Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => 8 | arr.slice(i * size, i * size + size) 9 | ) 10 | } 11 | 12 | export default async ({table, airtableRecords, transform, startTS}) => { 13 | // I wasn't having issues with uploads of up to 1000 records, so don't chunk them 14 | const chunkSize = airtableRecords.length < 1000 ? 1000 : 100 15 | 16 | const batches = batch(airtableRecords, chunkSize) 17 | 18 | let progress = 0 19 | for await (const chunk of batches) { 20 | const result = await prisma.client[table].createMany({ 21 | skipDuplicates: true, 22 | data: chunk.map(airtableRecord => transform(airtableRecord)) 23 | }) 24 | await new Promise(resolve => setTimeout(resolve, 1000)) 25 | progress += result.count 26 | 27 | console.log(`[${startTS}] Created ${progress}/${airtableRecords.length} ${table}(s)`) 28 | } 29 | return progress 30 | } 31 | -------------------------------------------------------------------------------- /jobs/remove-table.js: -------------------------------------------------------------------------------- 1 | import prisma from '../api/prisma.js' 2 | 3 | const removedTables = [] 4 | 5 | // ex. await removeTable('host', {dep: ['meeting']}) 6 | const dependencies = { 7 | 'host': ['meeting', 'errorLog'], 8 | 'meeting': ['webhookEvent', 'errorLog'], 9 | 'schedulingLink': ['meeting'], 10 | 'webhookEvent': [], 11 | 'authedAccount': ['schedulingLink'], 12 | } 13 | 14 | const removeTable = async (table, {startTS=Date.now(), depth=0}) => { 15 | const padding = ' '.repeat(depth) 16 | 17 | if (removedTables.includes(table)) { 18 | console.log(`${padding}[${startTS}] ${table}(s) already removed, skipping`) 19 | return 20 | } 21 | 22 | const deps = dependencies[table] 23 | if (deps) { 24 | for (const dep of deps) { 25 | console.log(`${padding}[${startTS}] ${table} depends on ${dep}...`) 26 | await removeTable(dep, {startTS, depth: depth+1}) 27 | } 28 | } 29 | console.log(`${padding}[${startTS}] Removing ${table}(s)...`) 30 | const results = await prisma.client[table].deleteMany() 31 | removedTables.push(table) 32 | console.log(`${padding}[${startTS}] Removed ${results.count} ${table}(s)`) 33 | } 34 | 35 | export default removeTable -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | _metadata: 2 | major_version: 1 3 | minor_version: 1 4 | display_information: 5 | name: /z 6 | description: "Create a Zoom Pro meeting. Find a bug? Bring it to #slash-z" 7 | background_color: "#2d2d38" 8 | features: 9 | app_home: 10 | home_tab_enabled: true 11 | messages_tab_enabled: false 12 | messages_tab_read_only_enabled: false 13 | bot_user: 14 | display_name: slash-z 15 | always_online: true 16 | slash_commands: 17 | - command: /z 18 | url: https://js-slash-z.hackclub.com/api/endpoints/slack 19 | description: Start a Zoom Pro meeting 20 | should_escape: true 21 | oauth_config: 22 | redirect_urls: 23 | - https://hack.af 24 | scopes: 25 | bot: 26 | - calls:read 27 | - calls:write 28 | - channels:read 29 | - chat:write 30 | - commands 31 | - users:read 32 | - bookmarks:read 33 | - bookmarks:write 34 | settings: 35 | event_subscriptions: 36 | request_url: https://slash-z.hackclub.com/api/endpoints/slack/events 37 | bot_events: 38 | - app_home_opened 39 | org_deploy_enabled: false 40 | socket_mode_enabled: false 41 | token_rotation_enabled: false 42 | -------------------------------------------------------------------------------- /docs/add-zoom-licenses.md: -------------------------------------------------------------------------------- 1 | Prerequisite: You must be a Zoom administrator 2 | 3 | 1) Login to zoom and navigate to the **Users Management** tab 4 | 5 | 6 | 7 | 2) Select the **Purchase more licenses** link. 8 | 9 | 3) Select **Edit Plan** 10 | 11 | 4) Increment the **Number of users** to the desired (increased) number of users. 12 | 13 | 5) Enter the requisite information that Zoom prompts you to provide. When asked for the email address, please use the precedent used for all of the other accounts (i.e. logins+slash-z-command-{NUMBER}@hackclub.com) 14 | 15 | 6) You will eventually be prompted to enter in the CVV of the credit card associated with the Zoom administrator account. Reach out to graham@hackclub.com or msw@hackclub.com for that information. 16 | 17 | 7) Login to logins@hackclub.com using 1password. Zoom should have sent an email to this address with an account activation link. Click that link. 18 | 19 | 8) Login to the Slash-Z Postgres database and add a new row to the **Hosts** table. The only unique values that need to be entered specific to the new host are the "email" and "displayName" columns (the rest is common and can be retrieved from other entries). 20 | -------------------------------------------------------------------------------- /api/endpoints/slack/events.js: -------------------------------------------------------------------------------- 1 | import ensureSlackAuthenticated from "../../ensure-slack-authenticated.js" 2 | import slackAppHomeOpened from '../../slack-app-home-opened.js' 3 | 4 | /** 5 | * Listen to slack bot events 6 | * @returns {Promise} 7 | */ 8 | export default async (req, res) => { 9 | return await ensureSlackAuthenticated(req, res, async () => { 10 | console.log(`Got verified Slack event of type '${req.body.type}'`) 11 | 12 | if (req.body.type == 'url_verification') { 13 | return res.send({ challenge: req.body.challenge }) 14 | } 15 | 16 | if (req.body.type == 'event_callback') { 17 | console.log(`Got event of subtype ${req.body.event.type}`) 18 | switch (req.body.event.type) { 19 | case 'app_home_opened': 20 | res.status(200).send() 21 | if (req.body.event.tab === 'home') { 22 | const { user } = req.body.event 23 | const result = await slackAppHomeOpened(user) 24 | } else { 25 | console.log(`False alarm, this user is opening the '${req.body.event.tab}' tab. I'm ignoring it.`) 26 | } 27 | break 28 | default: 29 | throw new Error(`Unsupported slack event: '${req.body.type}'`) 30 | } 31 | } 32 | }) 33 | } -------------------------------------------------------------------------------- /api/zoom-meeting-to-recording.js: -------------------------------------------------------------------------------- 1 | import ZoomClient from "./zoom-client.js" 2 | import Prisma from './prisma.js' 3 | 4 | /** 5 | * @param {string} zoomCallID 6 | * @returns {Object} 7 | */ 8 | export default async (zoomCallID) => { 9 | const meeting = await Prisma.find('meeting', { where: {zoomID: zoomCallID}, include: {host: true} }) 10 | const host = meeting.host 11 | 12 | const zoom = new ZoomClient({zoomSecret: host.apiSecret, zoomKey: host.apiKey}) 13 | 14 | const password = zoomCallID.toString().substring(0, 8) 15 | const results = {} 16 | 17 | await Promise.all([ 18 | // get meeting recordings from zoom 19 | zoom.get({path: `/meetings/${zoomCallID}/recordings`}).then(r => results.recording = r), 20 | 21 | // set password protection for a zoom recording 22 | zoom.patch({path: `/meetings/${zoomCallID}/recordings/settings`, body: { 23 | password 24 | }}) 25 | ]) 26 | // const recording = await zoom.get({ path: `/meetings/${zoomCallID}/recordings`}) 27 | // const settings = await zoom.get({ path: `/meetings/${zoomCallID}/recordings/settings`}) 28 | // console.log({settings}) 29 | // if () 30 | if (!results.recording) { 31 | throw new Error('Recording not found!') 32 | } 33 | return { ...results.recording, settings: { password } } 34 | } -------------------------------------------------------------------------------- /api/is-public-slack-channel.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | /** 4 | * Returns true if the channelID is a public slack channel 5 | * @function 6 | * @param {string} channelID - The ID of the slack channel 7 | * @returns {Promise} 8 | */ 9 | export default async function(channelID) { 10 | if (channelID[0].toLowerCase() != 'c') { 11 | // slack channels start with 'c' 12 | // this is probably a group 'g', dm 'd' or something else 13 | return false 14 | } 15 | 16 | let isPublic = true 17 | await Promise.all([ 18 | // check Slack to see if this channel is public 19 | fetch(`https://slack.com/api/conversations.info?channel=${channelID}`, { 20 | headers: { 21 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}` 22 | } 23 | }).then(r => r.json()).then(channelInfo => { 24 | isPublic = isPublic && ( 25 | channelInfo.ok && 26 | !channelInfo.channel['is_private'] && 27 | !channelInfo.channel['is_im'] && // is_im: private conversation between two individuals or with a bot 28 | !channelInfo.channel['is_mpim'] && // is_mpim: unnamed private conversation between multiple users 29 | !channelInfo.channel['is_group'] // is_group: private channel created before 2021 30 | ) 31 | }), 32 | 33 | ]) 34 | 35 | return isPublic 36 | } -------------------------------------------------------------------------------- /api/close-stale-calls.js: -------------------------------------------------------------------------------- 1 | import Prisma from "./prisma.js" 2 | import closeZoomCall from "./close-zoom-call.js" 3 | 4 | /** 5 | * Closes call older than two minutes with no participants 6 | * @param {Object} param 7 | * @param {string} param.creatorSlackID 8 | * @returns {any[]} 9 | */ 10 | export default async ({creatorSlackID} = {}) => { 11 | const startTS = Date.now() 12 | console.log(`Starting to close stale calls at ${startTS}`) 13 | 14 | const cutoff = 60 * 2 * 1000 // 2 minutes 15 | 16 | // get meetings started more than two minutes ago 17 | // that haven't ended 18 | const where = { 19 | endedAt: { 20 | equals: null, 21 | }, 22 | startedAt: { 23 | lt: new Date(new Date() - cutoff) 24 | } 25 | } 26 | 27 | const staleCalls = await Prisma.get('meeting', { where }) 28 | 29 | if (staleCalls.length == 0) { return 0 } 30 | 31 | const closedCalls = [] 32 | await Promise.all(staleCalls.map(async call => { 33 | const closedCall = await closeZoomCall(call.zoomID) 34 | 35 | if (closedCall) { 36 | closedCalls.push(closedCall) 37 | } 38 | })) 39 | 40 | await Prisma.create('customLogs', { text: `${closedCalls.length}_stale_calls_closed`, zoomCallId: closedCalls.toString() }) 41 | 42 | console.log(`I closed a total of ${closedCalls.length} call(s) from my task at ${startTS}`) 43 | 44 | return closedCalls 45 | } 46 | -------------------------------------------------------------------------------- /api/state.js: -------------------------------------------------------------------------------- 1 | import prisma from './prisma.js' 2 | import ZoomClient from "./zoom-client.js"; 3 | 4 | export async function getTotalHosts() { 5 | return prisma.count('host', { where: {enabled: true} }) 6 | } 7 | 8 | export async function getOpenHosts() { 9 | return prisma.count('host', { where: { 10 | enabled: true, 11 | meetings: { 12 | every: { NOT: { endedAt: { equals: null }}} 13 | } 14 | }}) 15 | } 16 | 17 | /* 18 | * Returns the total number of currently active users (cau) 19 | **/ 20 | export async function getCurrentlyActiveUsers() { 21 | const openCalls = await prisma.get("meeting", { 22 | where: { 23 | endedAt: null 24 | }, 25 | include: { host: true } 26 | }); 27 | 28 | let participants = await Promise.all(openCalls.map(async call => { 29 | const zoom = new ZoomClient({ 30 | zoomSecret: call.host.apiSecret, 31 | zoomKey: call.host.apiKey 32 | }); 33 | 34 | const zoomMetrics = await zoom.get({ 35 | path: `metrics/meetings/${call.zoomID}/participants` 36 | }); 37 | 38 | if (zoomMetrics == null || !("participants" in zoomMetrics)) { 39 | return 0; 40 | } 41 | 42 | return zoomMetrics.participants.filter(p => !Object.hasOwn(p, "leave_time")).length; 43 | })); 44 | 45 | return participants.reduce((acc, curr) => acc + curr, 0); 46 | } 47 | -------------------------------------------------------------------------------- /prisma/migrations/20220112180006_fix_ids_for_a_couple_models/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `hostZoomID` on the `Meeting` table. All the data in the column will be lost. 5 | - You are about to drop the column `callId` on the `WebhookEvent` table. All the data in the column will be lost. 6 | - Added the required column `hostID` to the `Meeting` table without a default value. This is not possible if the table is not empty. 7 | - Added the required column `meetingId` to the `WebhookEvent` table without a default value. This is not possible if the table is not empty. 8 | 9 | */ 10 | -- DropForeignKey 11 | ALTER TABLE "Meeting" DROP CONSTRAINT "Meeting_hostZoomID_fkey"; 12 | 13 | -- DropForeignKey 14 | ALTER TABLE "WebhookEvent" DROP CONSTRAINT "WebhookEvent_callId_fkey"; 15 | 16 | -- AlterTable 17 | ALTER TABLE "Meeting" DROP COLUMN "hostZoomID", 18 | ADD COLUMN "hostID" TEXT NOT NULL, 19 | ALTER COLUMN "slackChannelID" DROP NOT NULL; 20 | 21 | -- AlterTable 22 | ALTER TABLE "WebhookEvent" DROP COLUMN "callId", 23 | ADD COLUMN "meetingId" TEXT NOT NULL; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_hostID_fkey" FOREIGN KEY ("hostID") REFERENCES "Host"("zoomID") ON DELETE RESTRICT ON UPDATE CASCADE; 27 | 28 | -- AddForeignKey 29 | ALTER TABLE "WebhookEvent" ADD CONSTRAINT "WebhookEvent_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "Meeting"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 30 | -------------------------------------------------------------------------------- /api/get-public-meetings.js: -------------------------------------------------------------------------------- 1 | import Prisma from "./prisma.js" 2 | import transcript from "./transcript.js" 3 | import fetch from 'node-fetch' 4 | 5 | /** 6 | * Get the number of participants in a slack call 7 | * @function 8 | * @param {string} slackCallID - The ID of the slack call 9 | * @returns {Promise} 10 | */ 11 | async function getParticipantCount(slackCallID) { 12 | const callInfo = await fetch('https://slack.com/api/calls.info', { 13 | method: 'post', 14 | headers: { 15 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 16 | 'Content-Type': 'application/json' 17 | }, 18 | body: JSON.stringify({ 19 | id: slackCallID 20 | }) 21 | }).then(r => r.json()) 22 | if (!callInfo.call.users) { 23 | return 0 24 | } 25 | return callInfo.call.users.length 26 | } 27 | 28 | /** 29 | * Get a list of meetings having one or more participants 30 | * @function 31 | * @returns {Promise} 32 | */ 33 | export default async function() { 34 | const meetings = await Prisma.get('meeting', {where: {NOT: {startedAt: {equals: null}}, endedAt: {equals: null}, public: true}}) 35 | const meetingsWithParticipants = await Promise.all( 36 | meetings.map(async m => ({ 37 | channel: m.slackChannelID, 38 | channelFlavor: transcript(`channelFlavor.${m.slackChannelID}`, {}, null), 39 | joinURL: m.joinURL, 40 | participantCount: await getParticipantCount(m.slackCallID) 41 | })) 42 | ) 43 | return meetingsWithParticipants.filter(m => m.participantCount > 0) 44 | } -------------------------------------------------------------------------------- /api/endpoints/new-schedule-link.js: -------------------------------------------------------------------------------- 1 | import Prisma from "../prisma.js" 2 | import isProd from "../../isprod.js" 3 | 4 | export default async (req, res) => { 5 | console.log({name: req.query.id}) 6 | 7 | let user = await Prisma.find('authedAccount', { where: {name: req.query.id} }) 8 | 9 | if (!user) { 10 | user = await Prisma.create('authedAccount', {name: req.query.id}) 11 | } 12 | if (!user.slackID) { 13 | // No slack ID for this user? they're unauthenticated! Let's return an auth challenge 14 | const redirectUrl = isProd ? 'https://hack.af/z/slack-auth' : "https://slash-z-staging-1ae8b1c9e24a.herokuapp.com/api/endpoints/slack-auth" 15 | 16 | const state = { userID: user.id } 17 | console.log({state}) 18 | const stateString = encodeURIComponent(Buffer.from(JSON.stringify(state), "utf8").toString("base64")) 19 | 20 | const authUrl = `https://js-slash-z.hackclub.com/auth-start.html?response_type=code&redirect_uri=${encodeURIComponent(redirectUrl)}&user_scope=identify&client_id=${process.env.SLACK_CLIENT_ID}&state=${stateString}` 21 | return res.json({ 22 | error: 'AUTH', 23 | authUrl 24 | }) 25 | } 26 | 27 | // should open a meeting using 28 | // /api/endpoints/schedule-link?id= 29 | 30 | // let's spice this name creation up in the future too 31 | const id = Math.random().toString(36).substring(7) 32 | res.json({id, 33 | videoUri: `https://hack.af/z-join?id=${id}`, 34 | moreUri: `https://hack.af/z-phone?id=${id}`, 35 | stagingVideoUri: !isProd ? `https://slash-z-staging-1ae8b1c9e24a.herokuapp.com/api/endpoints/schedule-link?id=${id}` : null 36 | }) 37 | 38 | Prisma.create('schedulingLink', { 39 | name: id, 40 | creatorSlackID: user.slackID, 41 | authedAccountID: user.id, 42 | }) 43 | } -------------------------------------------------------------------------------- /api/update-slack-call-participant-list.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch" 2 | import md5 from "md5" 3 | import stringToColor from "./string-to-color.js" 4 | 5 | async function userToAvatar({name, email}) { 6 | const gravatarUrl = `https://gravatar.com/avatar/${md5(email.trim().toLowerCase())}?d=404` 7 | return await fetch(gravatarUrl).then(r => { 8 | if (r.ok) { 9 | return gravatarUrl 10 | } else { 11 | const fallbackAvatar = `https://ui-avatars.com/api/?name=${name}&background=${stringToColor(name)}` 12 | return fallbackAvatar 13 | } 14 | }) 15 | } 16 | 17 | export default async (addOrRemove, slackCallId, zoomParticipant) => { 18 | if (slackCallId == null) { 19 | console.log("Not a Slack-originated zoom. No need to update Slack call status") 20 | return null 21 | } 22 | 23 | const { user_name: name, email, user_id: zoomID } = zoomParticipant 24 | const user = { } 25 | // Why uniquify by name instead of zoomID? the ID zoom gives us changes per join for users under a non-hackclub account. 26 | // ex. someone@gmail.com (without a hack club zoom license) joins, leaves, & rejoins the call 27 | // their first participant_join event has user ID 16778240, & their second participant_join event has user ID 16790528 28 | user['external_id'] = name || email || zoomID 29 | user['avatar_url'] = await userToAvatar({name, email}) 30 | user['display_name'] = name 31 | 32 | const result = await fetch(`https://slack.com/api/calls.participants.${addOrRemove}`, { 33 | method: 'post', 34 | headers: { 35 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 36 | 'Content-Type': 'application/json; charset=utf-8' 37 | }, 38 | body: JSON.stringify({ 39 | id: slackCallId, 40 | users: [user] 41 | }) 42 | }).then(r => r.json()) 43 | console.log(result) 44 | return result 45 | } 46 | -------------------------------------------------------------------------------- /api/endpoints/slack/host-code.js: -------------------------------------------------------------------------------- 1 | import Prisma from "../../prisma.js"; 2 | import userIsRestricted from "../../user-is-restricted.js"; 3 | import channelIsForbidden from "../../channel-is-forbidden.js"; 4 | import transcript from '../../transcript.js'; 5 | import fetch from 'node-fetch'; 6 | 7 | const sendEphemeralMessage = (url, text) => { 8 | return fetch(url, { 9 | method: 'post', 10 | headers: { 11 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: JSON.stringify({ 15 | response_type: 'ephemeral', 16 | text: text 17 | }) 18 | }); 19 | }; 20 | 21 | export default async (req, res) => { 22 | const { user_id, response_url, channel_id, text } = req.body; 23 | 24 | if (await userIsRestricted(user_id)) { 25 | return sendEphemeralMessage(response_url, transcript('errors.userIsRestricted')); 26 | } 27 | 28 | if (channelIsForbidden(channel_id)) { 29 | return sendEphemeralMessage(response_url, transcript('errors.channelIsForbidden')); 30 | } 31 | 32 | if (!text) { 33 | return sendEphemeralMessage(response_url, transcript('errors.emptyHostCode')); 34 | } 35 | 36 | const meeting = await Prisma.find('meeting', { where: { zoomID: text } }); 37 | 38 | if (!meeting) { 39 | return sendEphemeralMessage(response_url, 'Unable to retrieve the host code. Please check the code and remove any Markdown formatting.'); 40 | } 41 | 42 | if (meeting.endedAt) { 43 | return sendEphemeralMessage(response_url, 'Cannot retrieve the host code for a concluded meeting.'); 44 | } 45 | 46 | if (meeting.creatorSlackID !== user_id) { 47 | return sendEphemeralMessage(response_url, '_You can only retrieve the host code for meetings you created._'); 48 | } 49 | 50 | return sendEphemeralMessage(response_url, `_Your meeting code is: *${meeting.hostKey}*_`); 51 | 52 | }; 53 | -------------------------------------------------------------------------------- /api/endpoints/schedule-link.js: -------------------------------------------------------------------------------- 1 | import findOrCreateMeeting from "../find-or-create-meeting.js" 2 | import { currentTimeHash } from "../time-hash.js" 3 | import isProd from "../../isprod.js" 4 | 5 | export default async (req, res) => { 6 | const { query } = req 7 | 8 | // No scheduling link ID? Let's redirect the user to get a new one 9 | if (!req.query || !req.query.id) { 10 | res.redirect('new-schedule-link') 11 | return 12 | } 13 | 14 | try { 15 | 16 | if (query.id === "rx0fbo") { 17 | return res.redirect(`https://hackclub.zoom.us/j/84489216040?pwd=UXNNNTJxQjV5dEdqTVNzbkE0RlpTZz09`) 18 | } 19 | 20 | // Special case for Hack Night 21 | if (query.id === "1vu13b" && query.key !== currentTimeHash()) { 22 | const state = { meetingID: query.id } 23 | const stateString = encodeURIComponent(Buffer.from(JSON.stringify(state), "utf8").toString("base64")) 24 | 25 | const redirectUrl = isProd ? 'https://hack.af/z/slack-auth' : "https://slash-z-staging-1ae8b1c9e24a.herokuapp.com/api/endpoints/slack-auth" 26 | // Redirect to Slack Auth specifying that it's /z 27 | return res.redirect(`https://slack.com/oauth/v2/authorize?response_type=code&redirect_uri=${encodeURIComponent(redirectUrl)}&user_scope=identify&client_id=${process.env.SLACK_CLIENT_ID}&state=${stateString}`) 28 | // Return to prevent creating a meeting if it's not necessary 29 | } 30 | 31 | if (query.id === "5s7xrr") { 32 | // Special case for George Hotz AMA, redirect to another Zoom link 33 | return res.redirect('https://hack.af/geohot-zoom') 34 | } 35 | 36 | const airtableMeeting = await findOrCreateMeeting(query.id) 37 | if (query.phone) { 38 | res.redirect('/phone.html?meetingID='+airtableMeeting.zoomID) 39 | } else { 40 | res.redirect(airtableMeeting.joinURL) 41 | } 42 | } catch (err) { 43 | res.status(err.statusCode || 500).send(err.message) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /race.test.js: -------------------------------------------------------------------------------- 1 | 2 | const baseUrl = "https://js-slash-z.herokuapp.com"; 3 | 4 | async function generateNewMeeting() { 5 | const response = await fetch(`${baseUrl}/api/endpoints/new-schedule-link`); 6 | const result = await response.json(); 7 | 8 | return result.id; 9 | } 10 | 11 | // sends n parallel requests to schedule link with id 12 | // returns the number of unique zoom calls that were started 13 | async function openParallelCalls(n, id) { 14 | const requests = []; 15 | for (let i = 0; i < n; i++) { 16 | const request = fetch(`${baseUrl}/api/endpoints/schedule-link?id=${id}`); 17 | requests.push(request); 18 | } 19 | 20 | const responses = await Promise.all(requests); 21 | const results = await Promise.all(responses.map(res => res.text())); 22 | 23 | const zoomUrls = results.map(result => { 24 | // find the index of the meta containing the zoom url 25 | const matchZoom = new RegExp(/ { 51 | const meetingName = await generateNewMeeting(); 52 | const numCalls = await openParallelCalls(2, meetingName); 53 | 54 | expect(numCalls).toBe(1); 55 | }, 6000); -------------------------------------------------------------------------------- /docs/certify.md: -------------------------------------------------------------------------------- 1 | ## How to test if slash-z runs properly 2 | 3 | ## Testing slack calls 4 | 5 | 1. Run `/z` command in your DM 6 | 2. A new call should be created and will look like this 7 | ![slack-call](img/slack-call.png) 8 | 3. Join the call by clicking the **Join** button 9 | 4. After joining the call, the call card should updated with your gravatar ![slack-call-update](img/slack-call-update.png) 10 | 5. Next, leave the call and your gravatar should disappear from the call card 11 | 12 | ## Testing scheduled calls 13 | 14 | 1. Start a new call by sending a GET request to [https://js-slash-z.herokuapp.com/api/endpoints/new-schedule-link](https://js-slash-z.herokuapp.com/api/endpoints/new-schedule-link) 15 | 2. Get and copy the property value of `videoUri` 16 | 3. paste the link in a new tab and take note of the zoom call id 17 | > given a call such as **https://hackclub.zoom.us/j/81651504037?pwd=YlE1ekFHckNhSm9GZDJtd2NZNnozQT09#success**, the zoom id is right after the /j path -- in this case **81651504037** 18 | 4. join the call 19 | 5. you may allow an additional person to join your same call to prove that the call link works properly 20 | 6. wait for two minutes while in the call 21 | 7. leave the call with your testing partner if any 22 | 8. If anything wrong happens, refer to [runbook] to investigate what went wrong 23 | 24 | ## Testing the garbage collector 25 | 26 | 1. Create a new call using /z in Slack and take note of the Meeting ID 27 | 2. Join the call and wait for ~2 minutes 28 | 3. Run `python3 scripts/zstory.py dissect -z ` to ensure the meeting has not been marked as ended 29 | - If it has been while you're still in the call, then start looking into closeZoomCall.js/closeStaleCalls.js to make sure calls are not forcibly closed under wrong conditions. 30 | 4. If not, leave the call and run the command from (3) again to make sure the call has been closed. 31 | 5. If the call was closed almost immediately after you left, then the garbage collector works properly. 32 | 33 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import './env.js' 2 | import './jobs/index.js' 3 | import transcript from './api/transcript.js' 4 | import { getTotalHosts, getOpenHosts, getCurrentlyActiveUsers } from "./api/state.js"; 5 | import express from 'express' 6 | import responseTime from 'response-time' 7 | import bugsnag from './bugsnag.js' 8 | import metrics from './metrics.js' 9 | import routes from './routes.js' 10 | 11 | const app = express() 12 | 13 | app.use(bugsnag().requestHandler) 14 | app.use(express.json()) 15 | app.use(express.urlencoded({extended: true})) 16 | app.use(responseTime(function (req, res, time) { 17 | app.use(express.static('public')) 18 | app.use(bugsnag().errorHandler) 19 | const stat = (req.method + req.url.split('?')[0]).toLowerCase() 20 | .replace(/[:.]/g, '') 21 | .replace(/\//g, '_') 22 | const httpCode = res.statusCode 23 | const timingStatKey = `http.response.${stat}` 24 | const codeStatKey = `http.response.${stat}.${httpCode}` 25 | metrics.timing(timingStatKey, time) 26 | metrics.increment(codeStatKey, 1) 27 | })) 28 | 29 | app.use(responseTime(function (req, res, time) { 30 | const stat = (req.method + req.url.split('?')[0]).toLowerCase() 31 | .replace(/[:.]/g, '') 32 | .replace(/\//g, '_') 33 | const httpCode = res.statusCode 34 | const timingStatKey = `http.response.${stat}` 35 | const codeStatKey = `http.response.${stat}.${httpCode}` 36 | metrics.timing(timingStatKey, time) 37 | metrics.increment(codeStatKey, 1) 38 | })) 39 | 40 | app.get('/ping', (req, res) => { 41 | res.send('pong!') 42 | }) 43 | 44 | // create endpoints for all files in the /api directory 45 | routes(app) 46 | 47 | const port = process.env.PORT || 0 48 | const listener = app.listen(port, () => { 49 | console.log(transcript('startup', {port: listener.address().port})) 50 | }) 51 | 52 | 53 | // Spit out global metrics every 1s 54 | setInterval(async () => { 55 | const total = await getTotalHosts() 56 | const open = await getOpenHosts() 57 | const active_users = await getCurrentlyActiveUsers(); 58 | 59 | metrics.gauge("hosts.open", open) 60 | metrics.gauge("hosts.total", total) 61 | metrics.gauge("app.active_users", active_users); 62 | }, 1000 * 60); -------------------------------------------------------------------------------- /api/endpoints/slack-auth.js: -------------------------------------------------------------------------------- 1 | import Prisma from '../prisma.js' 2 | import fetch from 'node-fetch' 3 | import findOrCreateMeeting from "../find-or-create-meeting.js" 4 | import isProd from '../../isprod.js' 5 | 6 | export default async (req, res) => { 7 | const {code, state: recordIDData} = req.query 8 | 9 | console.log({code, recordIDData}) 10 | 11 | const {userID, meetingID} = JSON.parse(Buffer.from(decodeURIComponent(recordIDData), "base64").toString()) 12 | 13 | console.log({code, recordIDData, userID, meetingID}) 14 | 15 | const tokenRedirectUri = isProd ? "https://hack.af/z/slack-auth" : "https://slash-z-staging-1ae8b1c9e24a.herokuapp.com/api/endpoints/slack-auth"; 16 | // Generate the token request 17 | const tokenUrl = 'https://slack.com/api/oauth.v2.access' + 18 | `?code=${code}` + 19 | `&client_id=${process.env.SLACK_CLIENT_ID}` + 20 | `&client_secret=${process.env.SLACK_CLIENT_SECRET}` + 21 | `&redirect_uri=${encodeURIComponent(tokenRedirectUri)}` 22 | 23 | console.log({code, recordIDData, userID, meetingID, tokenUrl}) 24 | 25 | if (meetingID === "1vu13b") { // Hack Night! 26 | const slackData = await fetch(tokenUrl, {method: 'post'}).then(r => r.json()) 27 | console.log(slackData) 28 | 29 | // Check if the authed user actually exists 30 | if (!slackData?.authed_user?.id) { // Instead of checking null, check any falsy value, such as undefined 31 | res.redirect('/auth-error.html') 32 | return 33 | } 34 | 35 | // Dynamically generate the meeting ID for future flexibility 36 | let meeting = await findOrCreateMeeting(meetingID) 37 | res.redirect(meeting.joinURL) 38 | return 39 | } 40 | 41 | const user = await Prisma.find('authedAccount', userID) 42 | 43 | if (user) { 44 | const slackData = await fetch(tokenUrl, {method: 'post'}).then(r => r.json()) 45 | await Prisma.patch('authedAccount', userID, { slackID: slackData['authed_user']['id'] }) 46 | // res.status(200).send('It worked! You can close this tab now') 47 | res.redirect('/auth-success.html') 48 | } else { 49 | // oh, we're far off the yellow brick road now... 50 | // res.status(422).send('Uh oh...') 51 | res.redirect('/auth-error.html') 52 | } 53 | } -------------------------------------------------------------------------------- /api/NOTES.md: -------------------------------------------------------------------------------- 1 | This is a notes doc where I can work through the Cloud Recording feature. 2 | 3 | I think I can get recordings once the meeting finishes. From there I can make a post in Slack(?). 4 | 5 | Maybe I should list it in the app homepage? 6 | 7 | First thing's first, let's make a list of all the meeting recordings in Airtable 8 | 9 | --- 10 | 11 | Ok, just tested out the API, I've got new thoughts on this. First, what I found: 12 | 13 | - Recording comes from Zoom Meeting ID 14 | - Recording returns 'nonexistant' while transcoding, so I don't know if a recording was never made, was made & deleted, or was made but is still transcoding 15 | - Most transcoding I tested took about 50-80% of the duration of the call (ie. hour long call took about 40 min to transcode) 16 | - Transcoding takes a minute minimum (ie. 10 second zoom call takes a minute before recording shows up on API) 17 | 18 | --- 19 | 20 | Before I start playing with extra DB tables & columns etc. I'm going to just link files from the app home screen. I'll check how the performance is & build out actual db changes when rate-limits/lag is becoming a problem. 21 | 22 | --- 23 | 24 | ![AYYYYYYY](https://cloud-olc8bplu4-hack-club-bot.vercel.app/0screen_shot_2021-04-27_at_16.20.22.png) 25 | 26 | https://marketplace.zoom.us/docs/api-reference/webhook-reference/recording-events/recording-started 27 | 28 | --- 29 | 30 | Looks like there are recording events for webhooks. This will be _soooo_ much easier. 31 | 32 | I'll add recording.started & recording.completed for now-- then I can have call states ('no recording', 'recording pending call end', 'recording pending transcoding', 'recording completed') 33 | 34 | --- 35 | 36 | How do we think through the records in Airtable? it's currently built with a rollup, but I'd like to avoid that if possible. sdhskjhadkjashdjksa 37 | agh 38 | 39 | i'm getting ahead of myself-- first, how do i handle the edge cases. there are simple ones: 40 | 41 | - what if the user doesn't have any recordings? 42 | - what if the user has recordings, but no completed recordings? 43 | 44 | and there are harder ones 45 | 46 | - what if the user wants to delete a recording? 47 | - how do we stop showing the record as deleted to the user? 48 | - when a recording finishes, how do i update the user's app homepage to show the recording as finished? -------------------------------------------------------------------------------- /docs/2021-04-02_account_transition_email.md: -------------------------------------------------------------------------------- 1 | Subject: Zoom Pro account transition 2 | From: max@hackclub.com 3 | 4 | --- 5 | 6 | Hi, I'm reaching out because you have a Zoom Pro license provided by Hack Club (for [RECIPIENT_EMAIL]), which will downgrade to Zoom Basic on April 23rd in favor of [/z](https://hack.af/z), our new method for creating Zoom Pro meetings. 7 | 8 | Last fall, [we promised to pay for Zoom Pro licenses to every club for a semester](https://hackclub.slack.com/archives/C0266FRGT/p1599681575158900). Since then we've been [building](https://hackclub.slack.com/archives/C0M8PUPU6/p1615326258370000) [a](https://hackclub.slack.com/archives/C0M8PUPU6/p1616619160322200)[better](https://hackclub.slack.com/archives/C0M8PUPU6/p1616681688357100) [alternative](https://hackclub.slack.com/archives/C0M8PUPU6/p1617115767045400) and now we're offering Zoom Pro meetings indefinitely through /z. These new meetings have the features of your own Zoom Pro account-- start one whenever you want, for as long as you want. 9 | 10 | You can start a meeting right now by running "/z" in a DM or public channel on the [Hack Club Slack](https://slack.hackclub.com/). 11 | 12 | ![Run slash z command](https://postal.hackclub.com/uploads/1617378399.gif) 13 | 14 | We even built a nifty calendar integration for /z you can install at [hack.af/z-install](https://hack.af/z-install), so you can schedule meetings straight from Google Calendar with two clicks: 15 | 16 | ![Schedule call on calendar](https://postal.hackclub.com/uploads/1617378120.gif) 17 | 18 | Everyone at HQ has been using only /z for the past month, to make sure we wouldn't ship anything that we're not using ourselves. 19 | 20 | Since HQ started providing Zoom Pro accounts to Hack Clubs in September, all of you and your clubs have done amazing things: 21 | 22 | - 6,498 meetings were hosted with a total of 80,796 participants 23 | - 3,185,449 minutes were spent in Hack Club meetings (that's 2,212 days, or 6+ years!) 24 | - Hack Clubbers have joined meetings from 112 countries 25 | 26 | Again, start using /z for your Zoom Meetings-- we're planning to support it long-term. You can find more details at [hack.af/z](https://hack.af/z), but please let me know if you have any questions. We will shut down all legacy Zoom Pro licenses on April 23rd. 27 | 28 | p.s. curious how it works? [the server](https://github.com/hackclub/slash-z) & [calendar addon](https://github.com/hackclub/slash-z-google-calendar) are open source 29 | 30 | \- Max -------------------------------------------------------------------------------- /docs/runbook.md: -------------------------------------------------------------------------------- 1 | # Slash-Z Runbook 2 | 3 | Generally, if you encounter an issue with slash-z — a malfunctioning or you find it misbehaving — create a related issue outlining the problem. 4 | 5 | ## References 6 | - Grafana Dashboard: http://telemetry.hackclub.com/ 7 | - Stale call: A Slack call with created two minutes ago relative to now with no participants in. 8 | 9 | ## Users end up in separate calls when using the same scheduled call link 10 | 1. Make sure the garbage collected is working properly by checking certify.md 11 | 2. If not, you will have to re-enable this. 12 | 13 | ## Slash-Z stops reporting to grafana 14 | 15 | 1. Check the logs of the slash-z Heroku dyno 16 | 2. If the app crashed, restart it 17 | 3. If the app occurs again, read the surrounding logs for relevant reasons for the crash . 18 | 19 | ## Slash-Z does not update the participants on the Slack call card 20 | 21 | 1. Confirm whether slash-z actually receives webhook events from zoom by running. 22 | - `python3 scripts/zstory.py dissect -z` in your terminal 23 | 2. If zstory.py shows webhook events for that meeting, check the slack app dashboard to make sure slash-z is properly configured 24 | - Confirm the App manifest in the slack dashboard matches [this](https://github.com/hackclub/slash-z/blob/master/manifest.yml) 25 | 3. Otherwise, check the zoom dashboard and make sure the app is validated by zoom to receive webhook events 26 | 27 | ## Grafana dashboard reports high zoom utilization 28 | 29 | 1. If the Grafana dashboard reports high zoom utilization 30 | - [Confirm the garbage collector works properly](#slash-z-calls-are-not-garbage-collected) 31 | 2. If the garbage collector is not enabled 32 | - Run /z many times in Slack till you get an "out of open hosts!" error 33 | - Run /z one more time to force slash-z to garbage collect stale calls 34 | 3. If this doesn't work, create and add new zoom licenses 35 | 36 | ## Slash-z calls are not garbage collected 37 | 38 | 1. Confirm the garbage collector service is enabled [here](https://github.com/hackclub/slash-z/blob/ebf4b49d3043c9b418d998fc2786a1cf7ab88238/jobs/index.js#L12C1-L24C2) 39 | 2. If its not, re-enable it and refer to [certify.md](./certify.md) to test if it works properly 40 | 41 | ## Slash-Z scheduled call links don't resolve 42 | 43 | This is most likely not a slash issue but rather an issue related to [hack.af](https://github.com/hackclub/hack.af). In case this is related to slash-z, it's best to check the slash-z logs to understand what actually happened. 44 | 45 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | import { resolve, relative, extname, basename, dirname } from 'path' 2 | import { readdir } from 'fs/promises' 3 | import os from "os"; 4 | import { pathToFileURL } from "url"; 5 | import recordError from './api/record-error.js' 6 | 7 | /** 8 | * Lists all files in {dir} 9 | * @function 10 | * @param {string} dir - The root directory name 11 | * @returns {Promise} 12 | */ 13 | async function getFiles(dir) { 14 | const dirents = await readdir(dir, { withFileTypes: true }) 15 | const files = await Promise.all(dirents.map((dirent) => { 16 | const res = resolve(dir, dirent.name) 17 | return dirent.isDirectory() ? getFiles(res) : res 18 | })) 19 | return Array.prototype.concat(...files) 20 | } 21 | 22 | /** 23 | * Creates an API endpoint for files in the /api directory 24 | * @function 25 | * @param {Object} app - An express app instance 26 | * @returns {Promise} 27 | */ 28 | export default async (app) => { 29 | const startTS = Date.now() 30 | const files = await getFiles('./api/endpoints') 31 | const filesToLoad = files.map(async file => { 32 | try { 33 | const ext = extname(file) 34 | if (!['.js', '.mjs'].includes(ext)) { 35 | // skip loading non-js files 36 | return 37 | } 38 | 39 | const moduleURL = new URL(import.meta.url); 40 | const __dirname = decodeURIComponent(dirname(moduleURL.pathname)); 41 | 42 | // if it's an index.js file, use the parent directory's name 43 | // ex. '/api/slack/index.js' is hosted at '/api/slack' (no index) 44 | let routePath = relative(__dirname, dirname(file)) 45 | // if it's NOT an index.js file, include the basename 46 | // ex. '/api/zoom/new.js' is hosted at '/api/zoom/new' 47 | if (basename(file, extname(file)) != 'index') { 48 | routePath = `${routePath}/${basename(file, extname(file))}` 49 | } 50 | 51 | routePath = os.platform() === "win32" ? routePath.split("\\").slice(7).join("/") : routePath; 52 | 53 | const fileUri = os.platform() === "win32" ? pathToFileURL(file) : file; 54 | const route = (await import(fileUri)).default // just to test we can load the file 55 | 56 | app.all('/' + routePath, async (req, res) => { 57 | try { 58 | await route(req, res) 59 | } catch (err) { 60 | console.error(err) 61 | recordError(err) 62 | } 63 | }) 64 | 65 | } catch (err) { 66 | console.error(err) 67 | console.log('Failed to load file:', file) 68 | } 69 | }) 70 | await Promise.all(filesToLoad) 71 | console.log(`Finished loading in ${Date.now() - startTS}ms`) 72 | } -------------------------------------------------------------------------------- /api/hack-night.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { currentTimeHash } from "./time-hash.js"; 3 | 4 | /** 5 | * Get the title of a bookmarked slack channel 6 | * @function 7 | * @returns {Promise} 8 | */ 9 | export async function fetchBookmarkTitle () { 10 | const response = await fetch('https://slack.com/api/bookmarks.list?channel_id=C0JDWKJVA', { 11 | headers: { 12 | Authorization: `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}` 13 | } 14 | }); 15 | const json = await response.json(); 16 | const { title } = json.bookmarks.filter(bookmark => bookmark.id === "Bk027L39LR9A")[0]; 17 | return title; 18 | } 19 | 20 | /** 21 | * Set the bookmark title of a slack channel 22 | * @function 23 | * @param {string} title - The new title of the bookmark 24 | * @returns {Promise} 25 | */ 26 | export async function setBookmarkTitle (title) { 27 | const response = await fetch('https://slack.com/api/bookmarks.edit', { 28 | method: 'POST', 29 | headers: { 30 | Authorization: `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 31 | "Content-Type": "application/json" 32 | }, 33 | body: JSON.stringify({ 34 | bookmark_id: 'Bk027L39LR9A', 35 | channel_id: 'C0JDWKJVA', 36 | emoji: ':zoom:', 37 | link: 'https://hack.af/night?key=' + currentTimeHash(), 38 | title 39 | }) 40 | }); 41 | const json = await response.json(); 42 | return json; 43 | } 44 | 45 | /** 46 | * Return the number of participants in a hack night call 47 | * @function 48 | * @returns {Promise} 49 | */ 50 | export async function fetchParticipantNumber () { 51 | const title = await fetchBookmarkTitle(); 52 | const number = +(title.split('').filter(char => !isNaN(char)).join('') || '0'); 53 | return number; 54 | } 55 | 56 | /** 57 | * Set the participant number in a hack night call 58 | * @function 59 | * @param {number} - number 60 | * @returns {Promise} 61 | */ 62 | export async function setParticipantNumber (number) { 63 | return await setBookmarkTitle('Join Hack Night! 👤 ' + number); 64 | } 65 | 66 | /** 67 | * Return the stats of a hack night call 68 | * @function 69 | * @param {string} event - The event name e.g "meeting.ended" 70 | * @param {any} meeting 71 | * @param {Object} payload 72 | * @returns {Promise} 73 | */ 74 | export default async function hackNightStats (event, meeting, payload) { 75 | switch (event) { 76 | case 'meeting.ended': 77 | await setParticipantNumber(0); 78 | break 79 | case 'meeting.participant_joined': { 80 | const participants = await fetchParticipantNumber(); 81 | await setParticipantNumber(participants + 1); 82 | break 83 | } 84 | case 'meeting.participant_left': { 85 | const participants = await fetchParticipantNumber(); 86 | await setParticipantNumber(participants - 1 < 0 ? 0 : participants - 1); 87 | break 88 | } 89 | default: {} 90 | } 91 | } -------------------------------------------------------------------------------- /public/support.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Support - Slash Z 6 | 7 | 8 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Hack Club 52 | 53 |
54 |

Support

55 |

Help and contact information for Slash Z.

56 |
57 | 58 |
59 |

Getting Help

60 |

We'll provide best-effort support for keeping Slash Z maintained and working for users.

61 |

Find a bug? Please report it (screenshots are super helpful!) by emailing us at slash-z@hackclub.com.

62 |

You can also check our Privacy Policy and Terms of Service.

63 |
64 | 65 | 70 | 71 | 74 | 75 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Host { 14 | id String @id @default(cuid()) 15 | email String 16 | enabled Boolean 17 | testing Boolean 18 | displayName String // previously "Name Displayed to Users" 19 | apiKey String 20 | apiSecret String 21 | zoomID String 22 | hostKey String? 23 | meetings Meeting[] 24 | errors String[] 25 | ErrorLog ErrorLog[] 26 | } 27 | 28 | model Meeting { 29 | id String @id @default(cuid()) 30 | zoomID String 31 | slackCallID String? 32 | host Host @relation(fields: [hostID], references: [id]) 33 | hostID String 34 | startedAt DateTime? 35 | endedAt DateTime? 36 | creatorSlackID String? 37 | joinURL String? 38 | hostJoinURL String? 39 | webhookEvents WebhookEvent[] 40 | rawWebhookEvents String? 41 | rawData String? 42 | slackChannelID String? 43 | public Boolean 44 | hostKey String? 45 | rawWebhookEventsTooLong Boolean @default(false) 46 | schedulingLink SchedulingLink? @relation(fields: [schedulingLinkId], references: [id]) 47 | schedulingLinkId String? 48 | ErrorLog ErrorLog[] 49 | } 50 | 51 | model WebhookEvent { 52 | id String @id @default(cuid()) 53 | meetingId String 54 | meeting Meeting @relation(fields: [meetingId], references: [id]) 55 | timestamp DateTime 56 | eventType String 57 | rawData String 58 | } 59 | 60 | model SchedulingLink { 61 | id String @id @default(cuid()) 62 | name String 63 | meetings Meeting[] 64 | meetingsIds String[] 65 | creatorSlackID String? 66 | authedAccount AuthedAccount @relation(fields: [authedAccountID], references: [id]) 67 | authedAccountID String 68 | } 69 | 70 | // Previously "Error" in the airtable version 71 | model ErrorLog { 72 | id String @id @default(cuid()) 73 | timestamp DateTime 74 | production Boolean 75 | text String 76 | stackTrace String 77 | meeting Meeting? @relation(fields: [meetingId], references: [id]) 78 | host Host? @relation(fields: [hostZoomID], references: [id]) 79 | meetingId String? 80 | hostZoomID String? 81 | } 82 | 83 | model AuthedAccount { 84 | id String @id @default(cuid()) 85 | name String? 86 | schedulingLinks SchedulingLink[] 87 | slackID String? 88 | } 89 | 90 | model CustomLogs { 91 | id String @id @default(cuid()) 92 | text String? 93 | zoomCallId String? 94 | } -------------------------------------------------------------------------------- /api/find-or-create-meeting.js: -------------------------------------------------------------------------------- 1 | import Bottleneck from 'bottleneck' 2 | import Prisma from './prisma.js' 3 | import openZoomMeeting from "./open-zoom-meeting.js" 4 | import sendHostKey from "./send-host-key.js"; 5 | 6 | /** 7 | * finds an existing meeting or create a new one for the query id if not found 8 | * @function 9 | * @param {string} queryID - The schedule link id 10 | * @returns {Promise} 11 | */ 12 | const findOrCreateMeeting = async (queryID) => { 13 | // Find the scheduling link record with the ID we've been given 14 | let link = await Prisma.find('schedulingLink', { 15 | where: {name: queryID} 16 | }) 17 | 18 | if (!link) { 19 | const err = Error('Scheduling meeting not found!') 20 | err.statusCode = 404 21 | throw err 22 | } 23 | 24 | // find open meetings using the schedule link id 25 | let openMeetingsCount = await Prisma.count('meeting', { where: { endedAt: { 26 | equals: null, 27 | }, schedulingLinkId: link.id } }) 28 | 29 | let airtableMeeting 30 | // if no OPEN meeting for the schedule link, let's create one now! 31 | if (openMeetingsCount == 0) { 32 | console.log(`No open meetings for scheduling link '${link.name}', creating a new one`) 33 | // start a meeting 34 | let zoomMeeting 35 | try { 36 | zoomMeeting = await openZoomMeeting({ isHackNight: link?.name == '1vu13b' }) 37 | } catch (err) { 38 | err.statusCode = 503 39 | throw err 40 | } 41 | // add it to the list of scheduled meetings 42 | const fields = {} 43 | fields.zoomID = zoomMeeting.id.toString() 44 | fields.host = {connect: { 45 | id: zoomMeeting.host.id 46 | }} 47 | fields.startedAt = new Date(Date.now()) 48 | fields.joinURL = zoomMeeting.join_url 49 | fields.schedulingLink = {connect: { 50 | id: link.id 51 | }} 52 | fields.hostJoinURL = zoomMeeting.start_url 53 | fields.public = false // hard coding this b/c scheduled meetings aren't shown on the public list atm 54 | fields.hostKey = zoomMeeting.hostKey 55 | if (link.creatorSlackID && link.name != '1vu13b') { // disable hack night 56 | fields.creatorSlackID = link.creatorSlackID 57 | 58 | // if it was a scheduled link with a creator, send a DM 59 | sendHostKey({creatorSlackID: fields.creatorSlackID, hostKey: fields.hostKey}) 60 | } 61 | 62 | airtableMeeting = await Prisma.create("meeting", fields) 63 | 64 | } else { 65 | console.log(`There's already an open meeting for scheduling link '${link.name}'`) 66 | airtableMeeting = await Prisma.find('meeting', { 67 | where: { 68 | schedulingLinkId: link.id, 69 | endedAt: { 70 | equals: null, 71 | } 72 | } 73 | }) 74 | } 75 | 76 | return airtableMeeting 77 | } 78 | 79 | const limiters = {} 80 | const getLimiter = id => { 81 | if (!limiters[id]) { 82 | limiters[id] = new Bottleneck({ maxConcurrent: 1 }) 83 | } 84 | 85 | return limiters[id] 86 | } 87 | 88 | export default (queryID) => getLimiter(queryID).schedule(() => findOrCreateMeeting(queryID)) 89 | -------------------------------------------------------------------------------- /public/phone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Slash Z - Dial In 6 | 7 | 8 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Hack Club 50 | 51 |
52 |

Slash Z

53 |

Dial In Information

54 |
55 | 56 |
57 | 58 | 59 |

If you scheduled this call, you can get your host key in Slack once the call has started.

60 |

Home | Privacy Policy | Support | Terms of Service

61 |
62 | 63 | 68 | 95 | 96 | -------------------------------------------------------------------------------- /public/auth-details.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 59 | 60 | 61 | 62 |
63 |
64 |

Guest Book of Orpheus Manor

65 |

To Whom It May Concern,

66 |

If you are about to sign this guest book, make sure you are prepared.

67 |

You will require, this guestbook & fountain pen, an understanding of the contract you are about to sign, and a sacrifice.

68 |

69 | While anyone is permitted onto the grounds of Orpheus Manor, only Hack Clubbers are permitted to open rooms up for meetings. 70 | Placing your name in this book will create a bond between souls - tethering your Slack User ID to your Gmail address. 71 | The binding process is can be undone upon request, but is used to verify you truly are a Hack Clubber before opening a room. 72 | You only need to sign here once. 73 |

74 |

Sincerely,

75 |

The Groundskeeper

76 |
77 | 92 | 93 |
94 | 95 | 96 | 103 | -------------------------------------------------------------------------------- /api/endpoints/zoom.js: -------------------------------------------------------------------------------- 1 | import closeZoomCall from "../close-zoom-call.js" 2 | import Prisma from "../prisma.js" 3 | import ensureZoomAuthenticated from "../ensure-zoom-authenticated.js" 4 | import updateSlackCallParticipantList from "../update-slack-call-participant-list.js" 5 | import slackAppHomeOpened from "../slack-app-home-opened.js" 6 | import hackNightStats from "../hack-night.js" 7 | import crypto from "crypto" 8 | 9 | async function getAssociatedMeeting(req) { 10 | try { 11 | const meetingId = req.body.payload.object.id; 12 | return await Prisma.find('meeting', { where: { zoomID: meetingId.toString() }, include: { schedulingLink: true } }) 13 | } catch { 14 | return null 15 | } 16 | } 17 | 18 | async function persistWebhookEventsIfNecessary(req, meeting) { 19 | if (!meeting) 20 | return 21 | 22 | await Prisma.create('webhookEvent', { 23 | timestamp: new Date(req.body.event_ts), 24 | eventType: req.body.event, 25 | rawData: JSON.stringify(req.body, null, 2), 26 | meeting: { connect: { id: meeting.id } } 27 | }) 28 | } 29 | 30 | async function handleSpecialHackNightLogic(req, meeting) { 31 | const isHackNight = meeting.schedulingLink?.name === "1vu13b"; 32 | 33 | if (isHackNight) 34 | await hackNightStats(req.body.event, meeting, req.body.payload); 35 | } 36 | 37 | // Zoom will sometimes send duplicate events, drop an event, or send an 38 | async function handleEvent(req, res, meeting) { 39 | // First, handle events that do not require a meeting 40 | switch(req.body.event) { 41 | case 'endpoint.url_validation': 42 | const encryptedToken = crypto.createHmac('sha256', process.env.ZOOM_WEBHOOK_SECRET_TOKEN).update(req.body.payload.plainToken).digest("hex"); 43 | const response = { 44 | plainToken: req.body.payload.plainToken, 45 | encryptedToken: encryptedToken 46 | } 47 | console.log(JSON.stringify(req.body)) 48 | console.log(JSON.stringify(response)) 49 | res.status(200).json(response) 50 | return; 51 | } 52 | 53 | if (!meeting) { 54 | console.log('Meeting not found, skipping...') 55 | res.status(404).send("That meeting does not exist") 56 | return 57 | } 58 | 59 | await handleSpecialHackNightLogic(req, meeting) 60 | 61 | switch (req.body.event) { 62 | case 'meeting.ended': 63 | await Prisma.create('customLogs', { text: 'zoom_end_meeting_webhook', zoomCallId: meeting.zoomID || "undefined" }) 64 | console.log('Attempting to close call w/ ID of', ) 65 | await closeZoomCall(meeting.zoomID, true) 66 | break 67 | 68 | case 'meeting.participant_joined': 69 | console.log('triggered!') 70 | await updateSlackCallParticipantList('add', meeting.slackCallID, req.body.payload.object.participant) 71 | break 72 | 73 | case 'meeting.participant_left': 74 | await updateSlackCallParticipantList('remove', meeting.slackCallID, req.body.payload.object.participant) 75 | break 76 | 77 | case 'recording.completed': 78 | await slackAppHomeOpened(meeting.creatorSlackID, false) 79 | break 80 | 81 | default: 82 | console.log(`Recieved '${req.body.event}' event from Zoom webhook, which I don't know how to process... Skipping`) 83 | console.log(`Just in case, here's the info:`) 84 | console.log(JSON.stringify(req.body, null, 2)) 85 | res.status(415).send("Unsupported webhook event") 86 | return; 87 | } 88 | 89 | res.status(200).send('Success!') 90 | } 91 | 92 | export default async (req, res) => { 93 | return await ensureZoomAuthenticated(req, res, async () => { 94 | console.log(`Recieved Zoom '${req.body.event}' webhook...`) 95 | const meeting = await getAssociatedMeeting(req); 96 | await persistWebhookEventsIfNecessary(req, meeting); 97 | await handleEvent(req, res, meeting); 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /api/close-zoom-call.js: -------------------------------------------------------------------------------- 1 | import ZoomClient from "./zoom-client.js"; 2 | import Prisma from "./prisma.js"; 3 | import metrics from "../metrics.js"; 4 | import fetch from "node-fetch"; 5 | 6 | /** 7 | * Closes a zoom call 8 | * @function 9 | * @param {string} zoomID - The zoom call id 10 | * @param {boolean} forceClose - force close the zoom call. Defaults to false 11 | * @returns {Promise} 12 | */ 13 | export default async (zoomID, forceClose = false) => { 14 | 15 | const meeting = await Prisma.find("meeting", { 16 | where: { zoomID }, 17 | include: { host: true } 18 | }) 19 | const host = meeting.host 20 | 21 | const zoom = new ZoomClient({ 22 | zoomSecret: host.apiSecret, 23 | zoomKey: host.apiKey, 24 | }); 25 | 26 | /** 27 | * Invalidates a zoom call id 28 | * @function 29 | */ 30 | const deleteMeeting = async () => { 31 | const response = await zoom.delete({ path: `meetings/${meeting.zoomID}` }); 32 | if (response.http_code == 400) { 33 | return metrics.increment("delete_meeting.warning", 1); 34 | } else if (response.http_code == 404) { 35 | // Report this also as a general error... 36 | metrics.increment("error.delete_meeting"); 37 | 38 | return metrics.increment("delete_meeting.error", 1); 39 | } 40 | return metrics.increment("delete_meeting.success", 1); 41 | }; 42 | 43 | // check if zoom meeting still has participants... 44 | const zoomMetrics = await zoom.get({ 45 | path: `metrics/meetings/${meeting.zoomID}/participants`, 46 | }); 47 | 48 | if(!zoomMetrics) { 49 | metrics.increment("error.metrics_not_defined", 1); 50 | await Prisma.create('customLogs', { text: `metrics_not_defined`, zoomCallId: meeting.zoomID }) 51 | return null; 52 | } 53 | 54 | // 400/404's denote meetings that do not exist. We need to clean them up on our side. 55 | // they also denote meetings where all participants have left 56 | if (!forceClose && (zoomMetrics.http_code == 400 || zoomMetrics.http_code == 404)) { 57 | 58 | await Prisma.create('customLogs', { text: `metrics_meeting_doesnt_exist`, zoomCallId: meeting.zoomID }) 59 | await Prisma.patch("meeting", meeting.id, { endedAt: new Date(Date.now()) }) 60 | 61 | // we need to delete the meeting if not already deleted 62 | await deleteMeeting(); 63 | 64 | return null; 65 | } 66 | 67 | if (!forceClose && zoomMetrics && zoomMetrics.total_records > 0) { 68 | console.log( 69 | `Meeting ${meeting.zoomID} has ${zoomMetrics?.total_records || "unknown"} participant(s). Not closing meeting. Run with forceClose=true to force close the meeting even with participants.` 70 | ); 71 | return null; 72 | } 73 | 74 | if (zoomMetrics.http_code == 200 || forceClose) { 75 | 76 | // 1) if was posted in slack, end slack call 77 | if (meeting.slackCallID) { 78 | const startTime = Date.parse(meeting.startedAt); 79 | const durationMs = Date.now() - startTime; 80 | const duration = Math.floor(durationMs / 1000); 81 | 82 | const _slackPost = await fetch("https://slack.com/api/calls.end", { 83 | method: "post", 84 | headers: { 85 | Authorization: `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 86 | "Content-Type": "application/json", 87 | }, 88 | body: JSON.stringify({ id: meeting.slackCallID, duration }), // hard coding duration while debugging 89 | }).then((r) => r.json()); 90 | } 91 | 92 | // 2) set the meeting end time 93 | await Prisma.patch("meeting", meeting.id, { endedAt: new Date(Date.now()) }) 94 | 95 | // delete the meeting from zoom to invalidate the url 96 | // this will happen iff there are no participants left in a call 97 | await deleteMeeting(); 98 | 99 | return meeting.id; 100 | } 101 | console.log("Not sure about the state of zoom call metrics, not closing the call"); 102 | }; 103 | -------------------------------------------------------------------------------- /prisma/migrations/20220112132642_first/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Host" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "enabled" BOOLEAN NOT NULL, 6 | "testing" BOOLEAN NOT NULL, 7 | "displayName" TEXT NOT NULL, 8 | "apiKey" TEXT NOT NULL, 9 | "apiSecret" TEXT NOT NULL, 10 | "zoomID" TEXT NOT NULL, 11 | "errors" TEXT[], 12 | 13 | CONSTRAINT "Host_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Meeting" ( 18 | "id" TEXT NOT NULL, 19 | "zoomID" TEXT NOT NULL, 20 | "slackCallID" TEXT, 21 | "hostZoomID" TEXT NOT NULL, 22 | "startedAt" TIMESTAMP(3), 23 | "endedAt" TIMESTAMP(3), 24 | "creatorSlackID" TEXT NOT NULL, 25 | "joinURL" TEXT NOT NULL, 26 | "hostJoinURL" TEXT NOT NULL, 27 | "rawWebhookEvents" JSONB, 28 | "rawData" JSONB, 29 | "slackChannelID" TEXT NOT NULL, 30 | "public" BOOLEAN NOT NULL, 31 | "hostKey" TEXT NOT NULL, 32 | "rawWebhookEventsTooLong" BOOLEAN NOT NULL DEFAULT false, 33 | "schedulingLinkId" TEXT NOT NULL, 34 | 35 | CONSTRAINT "Meeting_pkey" PRIMARY KEY ("id") 36 | ); 37 | 38 | -- CreateTable 39 | CREATE TABLE "WebhookEvent" ( 40 | "id" TEXT NOT NULL, 41 | "callId" TEXT NOT NULL, 42 | "timestamp" TIMESTAMP(3) NOT NULL, 43 | "eventType" TEXT NOT NULL, 44 | "rawData" JSONB NOT NULL, 45 | 46 | CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id") 47 | ); 48 | 49 | -- CreateTable 50 | CREATE TABLE "SchedulingLink" ( 51 | "id" TEXT NOT NULL, 52 | "name" TEXT NOT NULL, 53 | "meetingsIds" TEXT[], 54 | "creatorSlackID" TEXT NOT NULL, 55 | "authedAccountID" TEXT NOT NULL, 56 | 57 | CONSTRAINT "SchedulingLink_pkey" PRIMARY KEY ("id") 58 | ); 59 | 60 | -- CreateTable 61 | CREATE TABLE "ErrorLog" ( 62 | "id" TEXT NOT NULL, 63 | "timestamp" TIMESTAMP(3) NOT NULL, 64 | "production" BOOLEAN NOT NULL, 65 | "text" TEXT NOT NULL, 66 | "stackTrace" TEXT NOT NULL, 67 | "meetingId" TEXT, 68 | "hostZoomID" TEXT, 69 | 70 | CONSTRAINT "ErrorLog_pkey" PRIMARY KEY ("id") 71 | ); 72 | 73 | -- CreateTable 74 | CREATE TABLE "AuthedAccount" ( 75 | "id" TEXT NOT NULL, 76 | "name" TEXT, 77 | "slackID" TEXT, 78 | 79 | CONSTRAINT "AuthedAccount_pkey" PRIMARY KEY ("id") 80 | ); 81 | 82 | -- CreateTable 83 | CREATE TABLE "_AuthedAccountToSchedulingLink" ( 84 | "A" TEXT NOT NULL, 85 | "B" TEXT NOT NULL 86 | ); 87 | 88 | -- CreateIndex 89 | CREATE UNIQUE INDEX "Host_zoomID_key" ON "Host"("zoomID"); 90 | 91 | -- CreateIndex 92 | CREATE UNIQUE INDEX "_AuthedAccountToSchedulingLink_AB_unique" ON "_AuthedAccountToSchedulingLink"("A", "B"); 93 | 94 | -- CreateIndex 95 | CREATE INDEX "_AuthedAccountToSchedulingLink_B_index" ON "_AuthedAccountToSchedulingLink"("B"); 96 | 97 | -- AddForeignKey 98 | ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_hostZoomID_fkey" FOREIGN KEY ("hostZoomID") REFERENCES "Host"("zoomID") ON DELETE RESTRICT ON UPDATE CASCADE; 99 | 100 | -- AddForeignKey 101 | ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_schedulingLinkId_fkey" FOREIGN KEY ("schedulingLinkId") REFERENCES "SchedulingLink"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 102 | 103 | -- AddForeignKey 104 | ALTER TABLE "WebhookEvent" ADD CONSTRAINT "WebhookEvent_callId_fkey" FOREIGN KEY ("callId") REFERENCES "Meeting"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 105 | 106 | -- AddForeignKey 107 | ALTER TABLE "SchedulingLink" ADD CONSTRAINT "SchedulingLink_authedAccountID_fkey" FOREIGN KEY ("authedAccountID") REFERENCES "AuthedAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 108 | 109 | -- AddForeignKey 110 | ALTER TABLE "ErrorLog" ADD CONSTRAINT "ErrorLog_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "Meeting"("id") ON DELETE SET NULL ON UPDATE CASCADE; 111 | 112 | -- AddForeignKey 113 | ALTER TABLE "ErrorLog" ADD CONSTRAINT "ErrorLog_hostZoomID_fkey" FOREIGN KEY ("hostZoomID") REFERENCES "Host"("zoomID") ON DELETE SET NULL ON UPDATE CASCADE; 114 | 115 | -- AddForeignKey 116 | ALTER TABLE "_AuthedAccountToSchedulingLink" ADD FOREIGN KEY ("A") REFERENCES "AuthedAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; 117 | 118 | -- AddForeignKey 119 | ALTER TABLE "_AuthedAccountToSchedulingLink" ADD FOREIGN KEY ("B") REFERENCES "SchedulingLink"("id") ON DELETE CASCADE ON UPDATE CASCADE; 120 | -------------------------------------------------------------------------------- /public/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Privacy Policy - Slash Z 6 | 7 | 8 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Hack Club 53 | 54 |
55 |

Privacy Policy

56 |

How we handle your data for Slash Z at Hack Club.

57 |
58 | 59 |
60 |

Collection of Routine Information

61 |

Slash Z tracks basic information about users. This information includes, but is not limited to:

62 |
    63 |
  • Email address: used as a unique ID to tie a Slack account to a Google account.
  • 64 |
  • IP address: used for administration and debugging.
  • 65 |
  • Slack user ID: used as a unique ID to tie a Slack account to a Google account.
  • 66 |
  • Calendar event details: used to set up scheduled links.
  • 67 |
68 |
69 | 70 |
71 |

Cookies

72 |

We don't track nor intentionally set browser cookies.

73 |
74 | 75 |
76 |

Advertisement and Other Third Parties

77 |

We don't sell ads. See Slack's privacy policy and Zoom's privacy policy.

78 |
79 | 80 |
81 |

Security

82 |

The security of your personal information is important to us, but remember that no method of transmission over the Internet, or method of electronic storage, is 100% secure. While we strive to use commercially acceptable means to protect your personal information, we cannot guarantee its absolute security.

83 |
84 | 85 |
86 |

Changes To This Privacy Policy

87 |

This Privacy Policy is effective as of the date it is published. You can see previous changes with update times on our repo. We reserve the right to update or change our Privacy Policy at any time.

88 |
89 | 90 |
91 |

Contact Information

92 |

For any questions or concerns regarding the privacy policy, please reach out at slash-z@hackclub.com.

93 |
94 | 95 | 100 | 101 | 104 | 105 | -------------------------------------------------------------------------------- /api/zoom-client.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import fetch from 'node-fetch' 3 | import metrics from '../metrics.js' 4 | 5 | export default class ZoomClient { 6 | 7 | /** 8 | * instantiate a new zoom client 9 | * @constructor 10 | * @param {Object} props - an object containing the zoom api key and api secret 11 | * @param {string} props.zoomKey - The zoom api key 12 | * @param {string} props.zoomSecret- The zoom api secret 13 | * @returns {ZoomClient} 14 | */ 15 | constructor(props) { 16 | this.zoomKey = props.zoomKey 17 | this.zoomSecret = props.zoomSecret 18 | } 19 | 20 | 21 | /** 22 | * Sends a get request to zoom 23 | * @param {Object} opts - the parameter options 24 | * @param {string} opts.path - the endpoint e.g /metrics/ 25 | * @param {Object} opts.headers - the request headers 26 | * @returns {Object} 27 | */ 28 | async get(opts) { 29 | return this.request({...opts, method: 'get'}) 30 | } 31 | 32 | 33 | /** 34 | * Sends a post request to zoom 35 | * @param {Object} opts - the parameter options 36 | * @param {string} opts.path - the endpoint e.g /metrics/ 37 | * @param {Object} opts.headers - the request headers 38 | * @returns {Object} 39 | */ 40 | async post(opts) { 41 | return this.request({...opts, 42 | method: 'post', 43 | headers: {...opts.headers, 'content-type': 'application/json'}, 44 | }) 45 | } 46 | 47 | 48 | /** 49 | * Sends a patch request to zoom 50 | * @param {Object} opts - the parameter options 51 | * @param {string} opts.path - the endpoint e.g /metrics/ 52 | * @param {Object} opts.headers - the request headers 53 | * @returns {Object} 54 | */ 55 | async patch(opts) { 56 | return this.request({ 57 | ...opts, 58 | method: 'patch', 59 | headers: {...opts.headers, 'content-type': 'application/json'}, 60 | }) 61 | } 62 | 63 | 64 | /** 65 | * Sends a put request to zoom 66 | * @param {Object} opts - the parameter options 67 | * @param {string} opts.path - the endpoint e.g /metrics/ 68 | * @param {Object} opts.headers - the request headers 69 | * @returns {Object} 70 | */ 71 | async put(opts) { 72 | return this.request({ 73 | ...opts, 74 | method: 'put', 75 | headers: {...opts.headers, 'content-type': 'application/json'}, 76 | }) 77 | } 78 | 79 | 80 | /** 81 | * Sends a delete request to zoom 82 | * @param {Object} opts - the parameter options 83 | * @param {string} opts.path - the endpoint e.g /metrics/ 84 | * @param {Object} opts.headers - the request headers 85 | * @returns {Object} 86 | */ 87 | async delete(opts) { 88 | return this.request({ 89 | ...opts, 90 | method: 'delete', 91 | headers: { ...opts.headers, 'content-type': 'application/json' } 92 | }) 93 | } 94 | 95 | 96 | /** 97 | * Send a request to zoom 98 | * @param {Object} opts - the parameter options 99 | * @param {string} opts.path - the endpoint e.g /metrics/ 100 | * @param {Object} opts.headers - the request headers 101 | * @returns {Object} 102 | */ 103 | async request(opts) { 104 | const pathPrefix = opts.path.split("/")[0] 105 | console.log(opts) 106 | let startTimeMs = Date.now() 107 | const access_token = await this.token(); 108 | return fetch(`https://api.zoom.us/v2/${opts.path}`, { 109 | method: opts.method, 110 | headers: { 111 | authorization: `Bearer ${access_token}`, 112 | ...opts.headers 113 | }, 114 | body: JSON.stringify(opts.body) 115 | }).then(async r => { 116 | const httpCodeMetricName = `zoom.http.code.${pathPrefix}.${r.status}` 117 | const httpLatencyMetricName = `zoom.http.latency.${pathPrefix}.${r.status}` 118 | let elapsedTimeMs = startTimeMs - Date.now(); 119 | 120 | metrics.timing(httpLatencyMetricName, elapsedTimeMs) 121 | metrics.increment(httpCodeMetricName, 1) 122 | 123 | // Zoom sometimes responds with 204 for no content. 124 | // We don't want to try parsing JSON for this, because there is no JSON to parse 125 | console.log({response: r.ok}) 126 | 127 | if (r.ok && r.status != 204) { 128 | let payload = r.json() 129 | payload.http_code = r.status 130 | return payload 131 | } else if (r.status == 204) { 132 | return {http_code:r.status} 133 | } else { 134 | return {http_code:r.status} 135 | } 136 | }).catch(err => { 137 | metrics.increment("error.zoom_request_exception", 1) 138 | console.error(err) 139 | }) 140 | } 141 | 142 | /** 143 | * Generates a jwt for sending request 144 | * @method 145 | * @returns {Promise} 146 | */ 147 | async token() { 148 | const key = process.env.ZOOM_KEY; 149 | const account_id = process.env.ZOOM_ACCOUNT_ID; 150 | 151 | const response = await fetch(`https://zoom.us/oauth/token`, { 152 | method: "POST", 153 | headers: { 154 | "Content-Type": "application/x-www-form-urlencoded", 155 | "Host": "zoom.us", 156 | "Authorization": `Basic ${key}` 157 | }, 158 | body: `grant_type=account_credentials&account_id=${account_id}` 159 | }); 160 | const result = await response.json(); 161 | return result.access_token; 162 | } 163 | 164 | } -------------------------------------------------------------------------------- /api/transcript.js: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml' 2 | import { readFileSync } from 'fs' 3 | import path from 'path' 4 | import os from "os" 5 | 6 | /** 7 | * Returns the plural of {word} 8 | * @function 9 | * @param {string} word 10 | * @param {number} count 11 | * @returns {string} 12 | */ 13 | const pluralize = (word, count) => { 14 | // want to use this method? make sure to add your word to transcript.yml under 'plurals' 15 | if (count == 1) { 16 | // singular 17 | return `${count} ${word}` 18 | } else { 19 | // plural or zero 20 | return `${count} ${transcript('plurals.' + word)}` 21 | } 22 | } 23 | 24 | const sample = (arr) => { 25 | return arr[Math.floor(Math.random() * arr.length)] 26 | } 27 | 28 | /** 29 | * Loads the transcipt.yml file into an object 30 | * @function 31 | * @returns {string} 32 | */ 33 | const loadTranscript = () => { 34 | const moduleURL = new URL(import.meta.url); 35 | let __dirname = decodeURIComponent(path.dirname(moduleURL.pathname)); 36 | __dirname = os.platform() == "win32" ? __dirname.slice(1) : __dirname 37 | 38 | try { 39 | const doc = yaml.load( 40 | readFileSync(path.join(__dirname, '..', 'lib', 'transcript.yml'), 'utf8') 41 | ) 42 | return doc 43 | } catch (e) { 44 | console.error(e) 45 | } 46 | } 47 | 48 | /** 49 | * Recursively searches deep into the {transcriptObj}. 50 | * searchArr is a list such as ['plurals', 'participants'] 51 | * constructed from a string such as 'plurals.participants' 52 | * representing object access of the form { plurals: { participants: {}}} 53 | * @param {string[]} searchArr - A list of subsequent levels through which to search 54 | * @param {Object} transcriptObj - An object representation of transcript.yml 55 | * @param {any} fallback 56 | * @returns {Object|string} 57 | */ 58 | const recurseTranscript = (searchArr, transcriptObj, fallback) => { 59 | // start searching from the first item of the search array 60 | const searchCursor = searchArr.shift() 61 | const targetObj = transcriptObj[searchCursor] 62 | 63 | // if the item wasn't found in the array 64 | // return an new error 65 | // or return the fallback if a fallback was passed 66 | if (!targetObj) { 67 | if (typeof fallback == 'undefined') { 68 | return new Error('errors.transcript') 69 | // return new Error(transcript('errors.transcript')) 70 | } else { 71 | return fallback 72 | } 73 | } 74 | 75 | // if we haven't reached the deepest key, 76 | // keep going! 77 | if (searchArr.length > 0) { 78 | return recurseTranscript(searchArr, targetObj) 79 | } else { 80 | // if our target object is an array -- like a list item 81 | if (Array.isArray(targetObj)) { 82 | // pick one of the items and return it 83 | return sample(targetObj) 84 | } else { 85 | // otherwise return the target object 86 | return targetObj 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * Returns a plain object from an error 93 | * @param {string} key 94 | * @param {any} value 95 | * @returns {Object} 96 | */ 97 | const replaceErrors = (key, value) => { 98 | // from https://stackoverflow.com/a/18391400 99 | if (value instanceof Error) { 100 | const error = {} 101 | Object.getOwnPropertyNames(value).forEach(key => { 102 | error[key] = value[key] 103 | }) 104 | return error 105 | } 106 | return value 107 | } 108 | 109 | /** 110 | * Returns a value corresponding to {search} 111 | * from transcript.yml, replacing any placeholder with a variable 112 | * from {vars} 113 | * @function 114 | * @param {string} search - the word to transcribe 115 | * @param {Object} vars 116 | * @param {Object} fallback 117 | * @returns {string} 118 | */ 119 | const transcript = (search, vars, fallback) => { 120 | if (vars) { 121 | console.log( 122 | `I'm searching for words in my yaml file under "${search}". These variables are set: ${JSON.stringify( 123 | vars, 124 | replaceErrors 125 | )}` 126 | ) 127 | } else { 128 | console.log(`I'm searching for words in my yaml file under "${search}"`) 129 | } 130 | const searchArr = search.split('.') 131 | const transcriptObj = loadTranscript() 132 | const dehydratedTarget = recurseTranscript(searchArr, transcriptObj, fallback) 133 | return hydrateObj(dehydratedTarget, vars) 134 | } 135 | 136 | /** 137 | * Hydrates a javascript object 138 | * @param {Object|string|any[]} obj 139 | * @param {Object} vars 140 | * @returns {null|Object|any[]|string} 141 | */ 142 | const hydrateObj = (obj, vars = {}) => { 143 | if (obj == null) { 144 | return null 145 | } 146 | if (typeof obj === 'string') { 147 | return evalTranscript(obj, vars) 148 | } 149 | if (Array.isArray(obj)) { 150 | return obj.map(o => hydrateObj(o, vars)) 151 | } 152 | if (typeof obj === 'object') { 153 | Object.keys(obj).forEach(key => { 154 | obj[key] = hydrateObj(obj[key], vars) 155 | }) 156 | return obj 157 | } 158 | } 159 | 160 | 161 | /** 162 | * Replaces a variable in the yaml string with a value from var 163 | * @example var = {port: 3000}; target = "Hello ${this.port}", result = "Hello 3000" 164 | * @param {strng} target 165 | * @param {Object} vars 166 | * @returns {string} 167 | */ 168 | const evalTranscript = (target, vars = {}) => ( 169 | function () { 170 | return eval('`' + target + '`') 171 | }.call({ 172 | ...vars, 173 | t: transcript, 174 | pluralize 175 | }) 176 | ) 177 | 178 | export default transcript; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `/z` (Slash Z) 2 | 3 | [dinosaur chilling on a Zoom call with friends](https://cloud-av0cos2o5-hack-club-bot.vercel.app/1untitled_artwork_2.mp4) 4 | 5 | 6 | _maintained by [@grahamdarcey](https://github.com/grymmy), built by [@maxwofford](https://github.com/maxwofford) (initial version by [@zachlatta](https://github.com/zachlatta))_ 7 | 8 | #### Features 9 | 10 | - Zoom Pro meeting access 11 | - Scheduled meetings 12 | - Use anywhere in Slack 13 | - Free (for Hack Clubbers) 14 | - Fast 15 | 16 | #### Limitations 17 | 18 | - This is only intended for [Hack Clubbers](https://hack.af) in [the slack](https://hackclub.com/slack) 19 | 20 | ### Usage 21 | 22 | Go to any channel (or DM) in the Hack Club Slack & run `/z`. 23 | 24 | type '/z' into slack for a call! 25 | 26 | ## FAQ 27 | 28 | #### Can I schedule meetings? 29 | 30 | Yep! Scheduled meetings are done through the [Google Calendar 31 | addon](https://hack.af/z-addon). Install it for your account, then create a 32 | meeting by choosing the `/z` conference option on an event's settings page. 33 | 34 | #### Will my meeting have a 45 minute limit? 35 | 36 | No, Zoom meetings created on the Zoom Pro license stay as Pro accounts even 37 | if host is transferred to a Zoom Basic account. This also means you can 38 | transfer host to another participant who joins (like a co-leader) and the 39 | call will retain it's Zoom Pro status. 40 | 41 | #### How many people can join my call? 42 | 43 | Zoom Pro calls have a limit of 300 participants. Unfortunately this is a 44 | limit on Zoom's side, so we don't have much we can do to control it. 45 | 46 | #### How do I become the host of my meeting? 47 | 48 | If you create a meeting in Slack with `/z`, a public join link will be posted 49 | in the channel that anyone can click. Just underneath there will be a hidden 50 | message only shown to you with the *host key*, a 6 digit code you can use to 51 | promote yourself to host. 52 | 53 | If you create a meeting in Google Calendar, you'll find your *host key* on 54 | the app homepage of the @slash-z Slack bot. Your host key will only show up 55 | while a participant is in the meeting, so make sure to join it before looking 56 | for your host key. 57 | 58 | _Related: [Zoom's help page on using host keys](https://support.zoom.us/hc/en-us/articles/115001315866-Host-Key-Control-For-Zoom-Rooms)_ 59 | #### Can I give host access to my co-leads? 60 | 61 | Yes, once you are host of a meeting you can promote another participant to 62 | host by opening the "Participants" tab and clicking "Make host" next to 63 | their name. 64 | 65 | Additionally we've enabled Zoom's _co-hosting_ feature, that enables you to 66 | give host permissions to multiple participants in your call. To promote a 67 | participant to co-host, open the "Participants" tab and click "Make co-host" 68 | next to their name. 69 | 70 | _Related: [Zoom's help page on promoting a co-host](https://support.zoom.us/hc/en-us/articles/206330935-Enabling-and-adding-a-co-host#h_9c3ee7f2-b70c-4061-8dcf-00dd836b2075)_ 71 | 72 | #### Do I need a Hack Club Zoom account? 73 | 74 | No, you can start calls as well as claim host in calls from any Zoom account. 75 | You can even do it without signing into a Zoom account. 76 | #### Do I need to use a Hack Club email address? 77 | 78 | No, `/z` as well as the [Google Calendar addon](https://hack.af) work with 79 | any Gmail account that has permission to install addons. 80 | 81 | #### Can I use this on my personal Gmail account? 82 | 83 | That's fine to do! Please don't go crazy with multiple accounts, but the 84 | Google Calendar addon was built for people to install to their personal & 85 | work/school accounts. 86 | 87 | #### Does this work with a school Zoom account? 88 | 89 | It should, but every school puts different restrictions on their student accounts. 90 | 91 | If you run into issues creating or joining meetings while signed into a Zoom 92 | account provided by your school try signing out of Zoom and create/join your 93 | meetings from a logged out Zoom client. 94 | 95 | #### Does this work with a school Gmail account? 96 | 97 | It depends on the settings your school put on your Gmail account. 98 | 99 | Some schools will put restrictions on their student accounts to prevent 100 | installing new addons. You'll need to install our [addon for creating 101 | scheduled meetings](https://hack.af/z-addon). 102 | 103 | Your school-issued Gmail account shouldn't interfere with any meetings created in Slack with `/z`. 104 | 105 | #### Do you have a Gource? 106 | 107 | [Yes](https://www.youtube.com/watch?v=mJb_DeK6g1M) 108 | 109 | ## How it works 110 | 111 | Zoom has some really cool built-in features for taking over host status of a 112 | scheduled call. We have a list of paid Zoom Pro accounts that will host a new 113 | meeting when called upon. The join link is given to the meeting participant, 114 | as well as a host key that lets the user become host of the call. 115 | 116 | ## Local development / setup 117 | 118 | Following environment variables must be set: 119 | 120 | ``` 121 | DATABASE_URL 122 | 123 | SLACK_BOT_USER_OAUTH_ACCESS_TOKEN 124 | SLACK_CLIENT_ID 125 | SLACK_CLIENT_SECRET 126 | 127 | ZOOM_VERIFICATION_TOKEN 128 | ``` 129 | 130 | You can either set these in the environment or create a file called `.env` and set them, one per line with `=` separating the values. `slash-z` will automatically load the contents of `.env`. 131 | -------------------------------------------------------------------------------- /api/open-zoom-meeting.js: -------------------------------------------------------------------------------- 1 | import ZoomClient from "./zoom-client.js"; 2 | import Prisma from "./prisma.js"; 3 | // import sendHostKey from "./send-host-key.js"; 4 | import closeStaleCalls from "./close-stale-calls.js"; 5 | 6 | const hackNightSettings = { 7 | who_can_share_screen_when_someone_is_sharing: 'all', 8 | participants_share_simultaneously: 'multiple' 9 | } 10 | 11 | /** 12 | * Pick a random host not used in a call 13 | * @function 14 | * @returns {Promise} 15 | */ 16 | async function availableHost() { 17 | const hosts = await Prisma.get("host", { 18 | where: { 19 | enabled: true, 20 | meetings: { 21 | every: { 22 | NOT: { 23 | endedAt: { 24 | equals: null, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }); 31 | return hosts[Math.floor(Math.random() * hosts.length)]; 32 | } 33 | 34 | /** 35 | * Opens a new zoom meeting 36 | * @function 37 | * @param {Object} prop 38 | * @param {string} prop.creatorSlackID - The ID of the slack creator 39 | * @param {boolean} prop.isHackNight - Should the zoom meeting be a hack night 40 | * @returns {Object} 41 | */ 42 | export default async ({ creatorSlackID, isHackNight } = {}) => { 43 | // find an open host w/ less then 2 open meetings. why 2? Zoom lets us host up to 2 concurrent meetings 44 | // https://support.zoom.us/hc/en-us/articles/206122046-Can-I-Host-Concurrent-Meetings- 45 | // ¯\_(ツ)_/¯ 46 | let host = await availableHost(); 47 | 48 | // no free hosts? let's try closing some stale zoom calls 49 | if (!host) { 50 | console.log("No free hosts! I'm going to try closing stale calls"); 51 | const closedCalls = await closeStaleCalls({ creatorSlackID }); 52 | await Prisma.create('customLogs', { text: `${closedCalls.length}_stale_calls_closed_due_to_no_free_hosts`, zoomCallId: closedCalls.toString() }) 53 | if (closedCalls.length > 0) { 54 | host = await availableHost(); 55 | } 56 | } 57 | 58 | // still no free host? uh oh! let's reply back with an error 59 | if (!host) { 60 | throw new Error("out of open hosts!"); 61 | } 62 | 63 | // make a zoom client for the open host 64 | const zoom = new ZoomClient({ 65 | zoomSecret: host.apiSecret, 66 | zoomKey: host.apiKey, 67 | }); 68 | 69 | // no zoom id? no problem! let's figure it out and cache it for next time 70 | if (!host.zoomID || host.zoomID == "") { 71 | // get the user's zoom id 72 | const hostZoom = await zoom.get({ path: `users/${host.email}` }); 73 | host = await Prisma.patch("host", host.id, { 74 | zoomID: hostZoom.id 75 | }); 76 | 77 | // (max@maxwofford.com) This looks super redundant. Why are we also setting 78 | // these fields on meeting creation? Zoom's docs don't say it (at time of 79 | // writing), but zoom requires both the user's setting "host_video=true" for 80 | // the meeting "host_video=true" to work. ¯\_(ツ)_/¯ 81 | zoomUser = await zoom.patch({ 82 | path: `users/${host.zoomID}/settings`, 83 | body: { 84 | schedule_meeting: { 85 | host_video: true, 86 | participants_video: true, 87 | join_before_host: true, 88 | embeded_password_in_join_link: true, 89 | }, 90 | in_meeting: { 91 | breakout_room: true, 92 | file_transfer: true, 93 | co_host: true, 94 | polling: true, 95 | closed_caption: true, 96 | ...( 97 | (() => (isHackNight ? hackNightSettings : {}))() 98 | ) 99 | }, 100 | recording: { 101 | local_recording: true, 102 | cloud_recording: true, 103 | record_gallery_view: true, 104 | record_speaker_view: true, 105 | save_chat_text: true, 106 | // auto-delete cloud recordings after 60 days (maximum value for this setting) 107 | auto_delete_cmr: true, 108 | auto_delete_cmr_days: 60, // in days 109 | }, 110 | meeting_security: { 111 | embed_password_in_join_link: true, 112 | waiting_room: false, 113 | } 114 | }, 115 | }); 116 | } 117 | 118 | let hostKey = Math.random().toString().substr(2, 6).padEnd(6, 0); 119 | 120 | // sendHostKey({creatorSlackID, hostKey}) 121 | 122 | // attempt to set the host key 123 | const keyResponse = await zoom.patch({ 124 | path: `users/${host.zoomID}`, 125 | body: { host_key: hostKey }, 126 | }); 127 | 128 | // update the host record with the new key 129 | if (keyResponse.http_code === 204) { 130 | await Prisma.patch("host", host.id, { 131 | hostKey: hostKey 132 | }); 133 | } else { 134 | const hosts = await Prisma.get("host", { 135 | where: { 136 | email: host.email 137 | } 138 | }); 139 | // we know there are just two hosts with the same email 140 | // so we grab what's left 141 | const otherHost = hosts.filter(h => h.id != host.id)[0]; 142 | 143 | // re-assign the host key to the existing one 144 | hostKey = otherHost.hostKey; 145 | } 146 | 147 | // start a meeting with the zoom client 148 | const meeting = await zoom.post({ 149 | path: `users/${host.zoomID}/meetings`, 150 | body: { 151 | type: 2, // type 2 == scheduled meeting 152 | settings: { 153 | host_video: true, 154 | participant_video: true, 155 | join_before_host: true, 156 | waiting_room: false, 157 | }, 158 | }, 159 | }); 160 | 161 | return { 162 | ...meeting, 163 | displayName: host.displayName, 164 | host: host, 165 | hostKey: hostKey, 166 | }; 167 | }; 168 | -------------------------------------------------------------------------------- /api/slack-app-home-opened.js: -------------------------------------------------------------------------------- 1 | import transcript from './transcript.js' 2 | import fetch from 'node-fetch' 3 | import getPublicMeetings from './get-public-meetings.js' 4 | import getScheduledMeetings from './get-scheduled-meetings.js' 5 | import Prisma from './prisma.js' 6 | import zoomMeetingToRecording from './zoom-meeting-to-recording.js' 7 | 8 | const publishPage = async ({blocks, user})=> { 9 | return await fetch('https://slack.com/api/views.publish', { 10 | method: 'post', 11 | headers: { 12 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 13 | 'Content-Type': 'application/json' 14 | }, 15 | body: JSON.stringify({ 16 | user_id: user, 17 | view: { 18 | type: 'home', 19 | blocks 20 | } 21 | }) 22 | }).then(r => r.json()) 23 | } 24 | 25 | const publishLoadingPage = async user => { 26 | const result = await publishPage({user, 27 | ...transcript('appHome.loading') 28 | }) 29 | console.log(result) 30 | return result 31 | } 32 | 33 | const publishErrorPage = async ({user,err}) => { 34 | const result = await publishPage({user, 35 | ...transcript('appHome.error', {err}) 36 | }) 37 | console.log(result) 38 | return result 39 | } 40 | 41 | const publishHomePage = async ({user, results}) => { 42 | const blocks = [] 43 | blocks.push(transcript('appHome.greeting', {user})) 44 | blocks.push(transcript('appHome.divider')) 45 | if (results.publicMeetings.length > 0) { 46 | blocks.push(transcript('appHome.publicMeetings', {publicMeetings: results.publicMeetings})) 47 | blocks.push(transcript('appHome.divider')) 48 | } 49 | 50 | const {processing, completed} = results.recordings 51 | if (processing.length > 0) { 52 | blocks.push(transcript('appHome.recordedMeetings.processing', {processingCount: results.recordings.processing.length})) 53 | } 54 | 55 | if (completed.length > 0) { 56 | const completedRecordings = (await Promise.all(results.recordings.completed)).filter(r => r.share_url).map(c => ({ 57 | password: c.settings.password, 58 | url: c.share_url, 59 | meetingID: c.id, 60 | timestamp: `${(() => { 61 | const timestamp = Math.floor(new Date(c.start_time).valueOf() / 1000); 62 | const fallback = new Date(c.start_time).toLocaleString('en-US', { timeZone: user.tz }); 63 | const slackTimeString = ``; 64 | return slackTimeString; 65 | })()}`, 66 | duration: Math.max(c.duration, 1) // '0 minute call' -> '1 minute call' 67 | })) 68 | blocks.push(transcript('appHome.recordedMeetings.completedHeader', { count: completedRecordings.length })) 69 | completedRecordings.forEach(recording => { 70 | blocks.push(transcript('appHome.recordedMeetings.completedIndividual', { ...recording })) 71 | }) 72 | blocks.push(transcript('appHome.recordedMeetings.completedFooter')) 73 | } 74 | 75 | if (processing.length + completed.length > 0) { 76 | blocks.push(transcript('appHome.divider')) 77 | } 78 | 79 | blocks.push(transcript('appHome.calendarAddon.'+Boolean(results.user))) 80 | if (results.user) { // has access to the google calendar add-on 81 | const sm = results.scheduledMeetings 82 | if (sm.length > 1) { 83 | blocks.push(transcript('appHome.scheduledHostKeys.multiple', {sm})) 84 | } else if (sm.length == 1) { 85 | blocks.push(transcript('appHome.scheduledHostKeys.single', {hostKey: sm[0].meeting.hostKey})) 86 | } else { 87 | blocks.push(transcript('appHome.scheduledHostKeys.none')) 88 | } 89 | } 90 | blocks.push(transcript('appHome.divider')) 91 | const result = await publishPage({user, blocks}) 92 | if (!result.ok) { 93 | throw new Error(result.error) 94 | } 95 | console.log(result) 96 | return result 97 | } 98 | 99 | /** 100 | * 101 | * @param {string} user - user slack id 102 | */ 103 | const getUserInfo = async user => { 104 | return await Prisma.find('authedAccount', { where: { slackID: user } }) 105 | } 106 | 107 | /** 108 | * 109 | * @param {string} user - user slack id 110 | */ 111 | const getRecordings = async (user) => { 112 | const completedRecordingMeetings = await Prisma.get("meeting", { 113 | where: { 114 | creatorSlackID: user, 115 | webhookEvents: { 116 | some: { 117 | eventType: { 118 | contains: "recording.completed" 119 | }, 120 | }, 121 | }, 122 | }, 123 | }); 124 | const processing = await Prisma.get("meeting", { 125 | where: { 126 | creatorSlackID: user, 127 | webhookEvents: { 128 | some: { 129 | eventType: { 130 | contains: "recording.started" 131 | }, 132 | }, 133 | }, 134 | NOT: { 135 | webhookEvents: { 136 | some: { 137 | eventType: { 138 | contains: "recording.completed" 139 | }, 140 | }, 141 | }, 142 | }, 143 | }, 144 | }); 145 | const completed = (await Promise.all(completedRecordingMeetings.map(async meeting => { 146 | try { 147 | return await zoomMeetingToRecording(meeting.zoomID) 148 | } catch (err) { 149 | console.log(err) 150 | return null 151 | } 152 | }))).filter(Boolean) 153 | 154 | return { completed, processing } 155 | } 156 | 157 | export default async (user, loading=true) => { 158 | const results = {} 159 | try { 160 | const taskArray = [ 161 | getPublicMeetings().then(pm => results.publicMeetings = pm), 162 | getRecordings(user).then(r => results.recordings = r), 163 | getUserInfo(user).then(u => results.user = u), 164 | getScheduledMeetings(user).then(sm => results.scheduledMeetings = sm) 165 | ] 166 | 167 | // if running with the loading argument, show a loading page & ensure at 168 | // least 2 seconds of loading to prevent flashing the user with updates 169 | 170 | if (loading) { 171 | taskArray.push(new Promise(resolve => setTimeout(resolve, 1700))) 172 | taskArray.push(publishLoadingPage(user)) 173 | } 174 | 175 | await Promise.all(taskArray) 176 | 177 | await publishHomePage({user, results}) 178 | } catch (err) { 179 | await publishErrorPage({user, err}) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /api/prisma.js: -------------------------------------------------------------------------------- 1 | import pkg from "@prisma/client" 2 | import metrics from "../metrics.js" 3 | const { PrismaClient } = pkg 4 | import Bottleneck from 'bottleneck' 5 | 6 | const VERBOSE_PRISMA_LOGGING = process.env.VERBOSE_PRISMA_LOGGING ? false : process.env.VERBOSE_PRISMA_LOGGING=='true' 7 | 8 | const limiter = new Bottleneck({ 9 | maxConcurrent: 4, 10 | }) 11 | 12 | let prisma = new PrismaClient() 13 | // prismaGet('Meeting') 14 | // prismaGet('Meeting', '01234567') 15 | // prismaGet('Meeting', { where: {id: '01234567'} }) 16 | 17 | /** 18 | * Get all records matching clauses in options 19 | * @function 20 | * @param {string} table - The name of the database table 21 | * @param {Object} options - options 22 | * @returns {Promise} 23 | */ 24 | const get = async (table, options) => { 25 | const ts = Date.now() 26 | if (VERBOSE_PRISMA_LOGGING) 27 | console.log(`[${ts}] Trying to get '${table}' with options:`, options) 28 | 29 | let where, orderBy, include 30 | if (typeof options === 'string') { 31 | where = { id: options } 32 | } else { 33 | where = options.where 34 | orderBy = options.orderBy 35 | include = options.include 36 | } 37 | try { 38 | const results = await prisma[table].findMany({ where, orderBy, include }) 39 | if (VERBOSE_PRISMA_LOGGING) 40 | console.log(`[${ts}] Found ${results.length} record(s)`) 41 | metrics.increment("prisma.get.success", 1) 42 | return results 43 | } catch (err) { 44 | metrics.increment("prisma.get.failure", 1) 45 | console.error(err) 46 | } 47 | } 48 | 49 | // prismaFind('User') 50 | // prismaFind('User', '01234567') 51 | // prismaFind('User', ) 52 | 53 | /** 54 | * Returns a single record matching the clauses in options 55 | * @function 56 | * @param {string} table - The database table name 57 | * @param {Object} options - The query options 58 | * @returns {Promise} 59 | */ 60 | const find = async (table, options) => { 61 | const ts = Date.now() 62 | if (VERBOSE_PRISMA_LOGGING) 63 | console.log(`[${ts}] Trying to find '${table}' with options: '${JSON.stringify(options)}'`) 64 | let where, orderBy, include 65 | if (typeof options === 'string') { 66 | where = { id: options } 67 | } else { 68 | where = options.where 69 | orderBy = options.orderBy 70 | include = options.include 71 | } 72 | try { 73 | const result = await prisma[table].findFirst({ where, orderBy, include }) 74 | if (VERBOSE_PRISMA_LOGGING) 75 | console.log(`[${ts}] Found record with ID '${result.id}'`) 76 | metrics.increment("prisma.find.success", 1) 77 | return result 78 | } catch (err) { 79 | metrics.increment("prisma.find.failure", 1) 80 | console.log(err) 81 | } 82 | } 83 | 84 | /** 85 | * Returns the number of records in table 86 | * @function 87 | * @param {string} table - The database table name 88 | * @param {Object} - The query options 89 | * @returns {Promise} 90 | */ 91 | const count = async (table, options) => { 92 | let where, orderBy, include 93 | if (VERBOSE_PRISMA_LOGGING) 94 | console.log(`Trying to count '${table}' with options: '${JSON.stringify(options)}'`) 95 | if (typeof options === 'string') { 96 | where = { id: search } 97 | } else { 98 | where = options.where 99 | orderBy = options.orderBy 100 | include = options.include 101 | } 102 | try { 103 | const count = await prisma[table].count({ where, orderBy, include }) 104 | metrics.increment("prisma.count.success", 1) 105 | return count 106 | } catch (err) { 107 | metrics.increment("prisma.count.failure", 1) 108 | console.error(err) 109 | } 110 | } 111 | 112 | /** 113 | * Update fields on record with {recordID} in table 114 | * @function 115 | * @param {string} table - The table name in the database 116 | * @param {string} recordID - The ID of the record in table 117 | * @param {Object} fields - The fields to update on the record 118 | * @returns {Promise} 119 | */ 120 | const patch = async (table, recordID, fields) => { 121 | const ts = Date.now() 122 | try { 123 | if (VERBOSE_PRISMA_LOGGING) 124 | console.log(`[${ts}] PATCH '${table} ID ${recordID}' with the following fields:`, fields) 125 | const result = await prisma[table].update({ 126 | where: { 127 | id: recordID, 128 | }, 129 | data: fields 130 | }) 131 | if (VERBOSE_PRISMA_LOGGING) 132 | console.log(`[${ts}] PATCH successful!`) 133 | metrics.increment("prisma.patch.success", 1) 134 | return result 135 | } catch (err) { 136 | metrics.increment("prisma.patch.failure", 1) 137 | console.error(err) 138 | } 139 | } 140 | 141 | /** 142 | * Create a new record with {fields} in {table} 143 | * @function 144 | * @param {string} table - The database table name 145 | * @param {Object} fields - The new record's values 146 | * @returns {Promise} 147 | */ 148 | const create = async (table, fields) => { 149 | const ts = Date.now() 150 | try { 151 | const result = await prisma[table].create({ 152 | data: fields, 153 | }) 154 | if (VERBOSE_PRISMA_LOGGING) 155 | console.log(`[${ts}] Created my record with id: ${result.id}`) 156 | metrics.increment("prisma.create.success", 1) 157 | return result 158 | } catch (err) { 159 | metrics.increment("prisma.create.failure", 1) 160 | console.error(err) 161 | } 162 | } 163 | 164 | /** 165 | * Delete the record with id {id} from {table} 166 | * @function 167 | * @param {string} table - The database table name 168 | * @param {string} id - The id of the record to delete in {table} 169 | * @returns {Promise} 170 | */ 171 | const destroy = async (table, id) => { 172 | const ts = Date.now() 173 | try { 174 | if (VERBOSE_PRISMA_LOGGING) 175 | console.log(`[${ts}] DELETE '${table}' RECORD '${id}'`) 176 | const results = await prisma[table].delete({ 177 | where: { 178 | id: id, 179 | }, 180 | }) 181 | if (VERBOSE_PRISMA_LOGGING) 182 | console.log(`[${ts}] Deletion successful on '${table}' table, record '${id}'!`) 183 | metrics.increment("prisma.destroy.success", 1) 184 | return results 185 | } catch (err) { 186 | metrics.increment("prisma.destroy.failure", 1) 187 | console.error(err) 188 | } 189 | } 190 | 191 | export default { 192 | get: (...args) => limiter.schedule(() => get(...args)), 193 | find, 194 | count: (...args) => limiter.schedule(() => count(...args)), 195 | patch: (...args) => limiter.schedule(() => patch(...args)), 196 | create: (...args) => limiter.schedule(() => create(...args)), 197 | destroy: (...args) => deletionLimiter.schedule(() => destroy(...args)), 198 | client: prisma 199 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | Slash Z 16 | 17 | 18 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | Hack Club 98 | 99 |
100 |

Slash Z

101 |

Video calls for Hack Club community members, hassle-free.

102 |
103 | 104 |
105 |

Slash Z makes it easy for any Hack Club community member to schedule video calls, even without a Zoom Pro account. Just run /z in any Slack message!

106 |
107 | 108 |
109 |

How it works

110 |

Simply type /z in any Slack channel or direct message to instantly create a video call. No setup required!

111 | 112 |
113 |
114 | Slash Z demo showing the /z command in action 115 |
116 |
117 | Slash Z interface screenshot 118 |
119 |
120 |
121 | 122 |
123 |

Google Calendar add-on New ✨

124 |

Need to schedule in advance, or have recurring club meetings? No problem.

125 |

126 | You'll need to OAuth with Google and share calendar access so we can 127 | set up video-call links & add them to your events. Once you've 128 | approved the app, you'll be able to add video-calls with Slash-Z as 129 | easily as you would any other conference provider. 130 |

131 |

Keep in mind Slash Z is only provided to members in Hack Club's community, so you'll only be able to use the Google Calendar add-on if you already have a Slack Account that works with Slash Z.

132 | 133 |
134 | Google Calendar integration screenshot 135 |
136 |
137 | 138 |
139 |

Open Source

140 |

141 | Just like everything else we do at Hack Club, the source code 142 | 143 | for the server 144 | 145 | and 146 | 147 | for the Google Calendar add-on 148 | are all open-source; you can read it for yourself to understand how it works. 149 |

150 |

For more details, you can read our privacy policy, contact info, and terms of service.

151 |
152 | 153 | 158 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /api/endpoints/slack/slash-z.js: -------------------------------------------------------------------------------- 1 | import Prisma from "../../prisma.js" 2 | import isPublicSlackChannel from "../../is-public-slack-channel.js" 3 | import userIsRestricted from "../../user-is-restricted.js" 4 | import channelIsForbidden from "../../channel-is-forbidden.js" 5 | import openZoomMeeting from '../../open-zoom-meeting.js' 6 | import transcript from '../../transcript.js' 7 | import fetch from 'node-fetch' 8 | import metrics from '../../../metrics.js' 9 | 10 | export default async (req, res) => { 11 | console.log({ 12 | user_id: req.body.user_id, 13 | channel_id: req.body.channel_id, 14 | restricted: userIsRestricted(req.body.user_id), 15 | forbidden: channelIsForbidden(req.body.channel_id) 16 | }) 17 | 18 | if (await userIsRestricted(req.body.user_id)) { 19 | return fetch(req.body.response_url, { 20 | method: 'post', 21 | headers: { 22 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 23 | 'Content-Type': 'application/json' 24 | }, 25 | body: JSON.stringify({ 26 | response_type: 'ephemeral', 27 | text: transcript('errors.userIsRestricted') 28 | }) 29 | }) 30 | } 31 | 32 | if (channelIsForbidden(req.body.channel_id)) { 33 | return fetch(req.body.response_url, { 34 | method: 'post', 35 | headers: { 36 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 37 | 'Content-Type': 'application/json' 38 | }, 39 | body: JSON.stringify({ 40 | response_type: 'ephemeral', 41 | text: transcript('errors.channelIsForbidden') 42 | }) 43 | }) 44 | } 45 | 46 | const loadingSlackPost = await fetch(req.body.response_url, { 47 | method: 'post', 48 | headers: { 49 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 50 | 'Content-Type': 'application/json' 51 | }, 52 | body: JSON.stringify({ 53 | response_type: 'in_channel', 54 | text: 'A new Zoom Pro meeting was started with /z', 55 | }) 56 | }) 57 | 58 | let meeting 59 | try { 60 | meeting = await openZoomMeeting({ creatorSlackID: req.body.user_id }) 61 | } catch (err) { 62 | metrics.increment("error.no_hosts_available", 1) 63 | const errorSlackPost = await fetch(req.body.response_url, { 64 | method: 'post', 65 | headers: { 66 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 67 | 'Content-Type': 'application/json' 68 | }, 69 | body: JSON.stringify({ 70 | response_type: 'in_channel', 71 | text: 'Out of open hosts!', 72 | }) 73 | }) 74 | throw err 75 | } 76 | 77 | let displayName = req.body.user_name 78 | 79 | // now register the call on slack 80 | const slackCallFields = { 81 | external_unique_id: meeting.id, 82 | join_url: meeting.join_url, 83 | created_by: req.body.user_id, 84 | date_start: Math.floor(Date.now() / 1000), // Slack works in seconds, Date.now gives ms 85 | desktop_app_join_url: `zoommtg://zoom.us/join?confno=${meeting.id}&zc=0&pwd=${meeting.encrypted_password}`, 86 | external_display_id: meeting.id, 87 | title: `Zoom Pro meeting started by ${displayName}` 88 | } 89 | 90 | const isMeetingPublic = await isPublicSlackChannel(req.body.channel_id) 91 | 92 | const slackCallResult = await fetch('https://slack.com/api/calls.add', { 93 | headers: { 94 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 95 | 'Content-Type': 'application/json' 96 | }, 97 | method: 'post', 98 | body: JSON.stringify(slackCallFields) 99 | }).then(r => r.json()) 100 | const slackCall = slackCallResult.call 101 | 102 | // & post to slack + db! 103 | await Prisma.create('meeting', { 104 | zoomID: '' + meeting.id, 105 | slackCallID: slackCall.id, 106 | host: {connect: { 107 | id: meeting.host.id 108 | }}, 109 | startedAt: new Date(), 110 | creatorSlackID: req.body.user_id, 111 | joinURL: meeting.join_url, 112 | hostJoinURL: meeting.start_url, 113 | rawData: JSON.stringify(meeting, null, 2), 114 | slackChannelID: req.body.channel_id, 115 | public: isMeetingPublic, 116 | hostKey: meeting.hostKey 117 | }) 118 | 119 | const slackPostFields = { 120 | response_type: 'in_channel', 121 | text: 'A new Zoom Pro meeting was started with /z', 122 | blocks: [{ 123 | type: 'section', 124 | text: { 125 | type: 'mrkdwn', 126 | text: `After running \`/z\`, you wander the creaky hallways and stumble upon the *${meeting.displayName}*. You try it and the door is unlocked.` 127 | } 128 | }, { 129 | type: 'call', 130 | call_id: slackCall.id 131 | }, { 132 | type: 'section', 133 | text: { 134 | type: 'mrkdwn', 135 | text: `_Psst ${meeting.join_url} is the call link._` 136 | } 137 | }] 138 | } 139 | 140 | if (meeting.password) { 141 | slackPostFields.blocks.push({ 142 | type: 'section', 143 | text: { 144 | type: 'mrkdwn', 145 | text: `_The meeting password is *${meeting.password}*_.` 146 | } 147 | }) 148 | } 149 | const slackPost = await fetch(req.body.response_url, { 150 | method: 'post', 151 | headers: { 152 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 153 | 'Content-Type': 'application/json' 154 | }, 155 | body: JSON.stringify(slackPostFields) 156 | }) 157 | 158 | await fetch(req.body.response_url, { 159 | method: 'post', 160 | headers: { 161 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 162 | 'Content-Type': 'application/json' 163 | }, 164 | body: JSON.stringify({ 165 | response_type: 'ephemeral', 166 | text: ':key: You find a golden key', 167 | blocks: [{ 168 | type: 'section', 169 | text: { 170 | type: 'mrkdwn', 171 | text: `You find a hastily scribbled note on the ground. You find the numbers *${meeting.hostKey}* you can use to of the *${meeting.displayName}*.` 172 | } 173 | }] 174 | }) 175 | }) 176 | 177 | try { 178 | 179 | await fetch('https://slack.com/api/chat.postMessage', { 180 | method: 'post', 181 | headers: { 182 | 'Authorization': `Bearer ${process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN}`, 183 | 'Content-Type': 'application/json' 184 | }, 185 | body: JSON.stringify({ 186 | "channel": "C02FAFA2JTT", // hardcode channel ID 187 | "blocks": [ 188 | { 189 | "type": "section", 190 | "text": { 191 | "type": "mrkdwn", 192 | "text": `*New Zoom Meeting*\nUser: <@${req.body.user_id}> (${req.body.user_id})\nChannel: <#${req.body.channel_id}> (${req.body.channel_id})\nPublic Meeting? ${isMeetingPublic}\nZoom ID: ${meeting.id}` 193 | }, 194 | "accessory": { 195 | "type": "image", 196 | "image_url": "https://cloud-nz8prdq79-hack-club-bot.vercel.app/0image.png", 197 | "alt_text": "slashz logo" 198 | } 199 | }, 200 | { 201 | "type": "divider" 202 | } 203 | ] 204 | }) 205 | }) 206 | 207 | } catch (error) { // just in case I completely break /z 208 | console.error(error); 209 | 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/transcript.yml: -------------------------------------------------------------------------------- 1 | # the pun list™℠®© 2 | bones: 3 | - skulls 4 | - spines 5 | - metacarpals 6 | - phalanges 7 | - scapula 8 | - humerous 9 | - funny bones 10 | - coccyx 11 | copyrigtSymbols: 12 | - ® 13 | - ™ 14 | - ℠ 15 | - © 16 | - ℗ 17 | plurals: 18 | participant: participants 19 | public meeting: public meetings 20 | recording: recordings 21 | minute: minutes 22 | publicMeetings: 23 | none: | 24 | https://cloud-rfuqitcwv-hack-club-bot.vercel.app/0510b43.jpg 25 | There aren't any calls running in public Slack channels right now. You can start one yourself in any channel by running \`/z\`. 26 | single: 27 | - There is <${this.meeting.joinUrl}| only one call to join>, but it is precious nonetheless. It's in <#${this.meeting.channel}>. 28 | - This is <${this.meeting.joinUrl}| the only running call>, and I'd protect it with my life. It's in <#${this.meeting.channel}>. 29 | multiple: | 30 | Here are the public meetings currently running: 31 | ${this.meetings.map(m => this.t('publicMeetings.lineItem', { m })).join('\n')} 32 | lineItem: "- <${this.m.joinURL}| Join a call> in <#${this.m.channel}>${this.m.channelFlavor ? ' _(' + this.m.channelFlavor + ')_' : '' } with ${this.pluralize('participant', this.m.participantCount)}" 33 | footnoteSymbols: 34 | - † 35 | - ‡ 36 | - "[1]" 37 | - "[1][2]" 38 | - "*" 39 | - § 40 | loadingEmoji: 41 | - spookytime 42 | - doot 43 | - skelly-dance 44 | - skelly-dance-rainbow 45 | - boogie-skeleton 46 | - doot-animated 47 | loadingGifs: 48 | - https://cloud-f8eoienmp-hack-club-bot.vercel.app/0skelly-dance-rainbow.gif 49 | - https://cloud-f8eoienmp-hack-club-bot.vercel.app/1boogie-skeleton.gif 50 | - https://cloud-f8eoienmp-hack-club-bot.vercel.app/2doot-animated.gif 51 | - https://cloud-f8eoienmp-hack-club-bot.vercel.app/3skelly-dance-large.gif 52 | errorGifs: 53 | - https://cloud-7bxq4c1sz-hack-club-bot.vercel.app/0ezgif.com-gif-maker.1.gif 54 | # - https://cloud-6ijesklkt-hack-club-bot.vercel.app/0giphy.webp 55 | skeletonVideos: 56 | - https://www.youtube.com/watch?v=XqVtNjyCQE0 57 | - https://www.youtube.com/watch?v=-1dSY6ZuXEY 58 | - https://youtu.be/z6WMbV5Op58?t=8 59 | - https://www.youtube.com/watch?v=vOGhAV-84iI 60 | errorViewer: | 61 | \`\`\` 62 | ${this.err.stack} 63 | \`\`\` 64 | sheriffs: 65 | - bones 66 | - good zoom calls 67 | - spook 68 | - stealing your bones 69 | - drinking lots of milk 70 | loading: 71 | - creep it real! 72 | - juggling the ${this.t('bones')} 73 | - opening the bone bag 74 | - \*Slash Z${this.t('copyrightSymbols')}\* _it's a ton¹ of skele-fun!²_ 75 | - \*Slash Z${this.t('copyrightSymbols')}\* _it's a skele-ton¹ of fun!²_ 76 | - loading could take some time... looks like this app is run by a _skeleton_ crew 77 | - ":bone: :clap: :bone: :clap: :bone: :clap: :bone:" 78 | - ":skull_and_crossbones: :clap: :skull_and_crossbones: :clap: :skull_and_crossbones: :clap: :skull_and_crossbones:" 79 | - | # This spagetti is the text for "i'm the sheriff of X" memes 80 | Howdy, i'm the sheriff of ${this.t('sheriffs')} 81 | :blank: :blank: :face_with_cowboy_hat: 82 | :blank: :bone: :bone: :bone: 83 | :bone::blank::bone: :blank: :bone: 84 | :point_down: :bone::bone: :point_down: 85 | :bone: :bone: 86 | :bone: :bone: 87 | :boot: :boot: 88 | - Let's boogie on down to skelly-town! 89 | - If you've got it, haunt it 90 | - Shake your boo-ty! 91 | - Keep it incorporeal! 92 | - Make sure to exorcise regularly! 93 | - You feel like you're going to have a good time. 94 | - I can't think of anything else humerus. 95 | - Don't worry, ulna't tell anyone. 96 | - Don't be so sternum. 97 | - Are you spine on me? 98 | - Loading up the *telebone* 99 | - This is going tibia great one 100 | - Bone-apple-tea! 101 | - Bone-Appetit! 102 | - bone to be wild! 103 | - These jokes are very bare bones 104 | startup: 105 | - Haunting on port ${this.port} 106 | - Port ${this.port} is about to get spooky! 107 | - Arrrrr! Hard to port ${this.port}! 108 | currentDay: "${(new Date()).toLocaleDateString('en-US', { weekday: 'long' })}" 109 | greeting: 110 | - Welcome to the Manor <@${this.user}>! 111 | - Hello <@${this.user}>, have a good ${this.t('currentDay')}! 112 | - Hello <@${this.user}>, your bones look wonderful today! 113 | - Greetings <@${this.user}>, please come in! 114 | appHome: 115 | error: 116 | blocks: 117 | - type: header 118 | text: 119 | type: plain_text 120 | text: Uh oh... 121 | - type: section 122 | text: 123 | type: mrkdwn 124 | text: Please send <@U0C7B14Q3> a screenshot of this so he can fix it # <-- Max's Slack ID 125 | - type: image 126 | image_url: ${this.t('errorGifs')} 127 | alt_text: 'skeleton falling apart' 128 | - type: section 129 | text: 130 | type: mrkdwn 131 | text: "${this.t('errorViewer', {err: this.err})}" 132 | loading: 133 | blocks: 134 | - type: section 135 | text: 136 | type: mrkdwn 137 | text: ":beachball: ${this.t('loading')}" 138 | - type: image 139 | image_url: ${this.t('loadingGifs')} 140 | alt_text: 'dancing skeletons' 141 | greeting: 142 | type: section 143 | text: 144 | type: mrkdwn 145 | text: "${this.t('greeting', {user: this.user})}" 146 | divider: 147 | type: divider 148 | publicMeetings: 149 | type: section 150 | text: 151 | type: mrkdwn 152 | text: | 153 | There are ${this.pluralize('public meeting', this.publicMeetings.length)} open right now. 154 | ${this.publicMeetings.length > 0 ? this.t('publicMeetings.multiple', {meetings: this.publicMeetings}) : ''} 155 | recordedMeetings: 156 | processing: 157 | type: section 158 | text: 159 | type: mrkdwn 160 | text: You have ${this.pluralize('recording', this.processingCount)} still processing :beachball:. Zoom usually takes the length of the call to process a video, so come back soon! 161 | completedHeader: 162 | type: section 163 | text: 164 | type: mrkdwn 165 | text: You have ${this.pluralize('recording', this.count)} ready for download. 166 | completedIndividual: 167 | # @msw: Slack has an undocumented 3000 character per block limit, so we 168 | # need to put each meeting recording in their own block– after ~15 169 | # recordings we reach the limit. 170 | type: section 171 | text: 172 | type: mrkdwn 173 | text: | 174 | - <${this.url}|Meeting ${this.meetingID} _(${this.pluralize("minute", this.duration)} long)_> (password *${this.password}*) - ${this.timestamp} 175 | completedFooter: 176 | type: section 177 | text: 178 | type: mrkdwn 179 | text: _Recorded meetings will automatically by removed after 60 days._ 180 | calendarAddon: 181 | true: 182 | type: section 183 | text: 184 | type: mrkdwn 185 | text: "You've installed the Google Calendar add-on. You can make scheduled calls through Google Calendar." 186 | false: 187 | type: section 188 | text: 189 | type: mrkdwn 190 | text: "Want to create a scheduled meeting link? You can install the the Google Calendar add-on at https://hack.af/z-addon." 191 | scheduledHostKeys: 192 | multiple: 193 | type: section 194 | text: 195 | type: mrkdwn 196 | text: | 197 | Here are your currently running calls: 198 | ${this.sm.map(m => `- Call _${m.link.name}_ has host key *${m.meeting.hostKey}*`)} 199 | single: 200 | type: header 201 | text: 202 | type: plain_text 203 | text: "Your host key is ${this.hostKey}. Have a wonderful meeting!" 204 | none: 205 | type: section 206 | text: 207 | type: mrkdwn 208 | text: "Once you join a meeting scheduled on your calendar, your host key will show up here." 209 | publicChannelFlavor: 210 | C0P5NE354: # bot-spam 211 | - like <#C012YMFQHUG>, but probably more lonely! 212 | - you can ignore this channel-- probably just someone debugging 213 | - I don't recommend joining this one-- probably just someone testing new features 214 | C012YMFQHUG: # productivity 215 | - great for productivity 216 | - work it! 217 | - <@U0C7B14Q3> unite! # @coworking-regulars 218 | C0JDWKJVA: # hack-night 219 | - great for procrastination 220 | - "awoooo! :wolf: :moon:" 221 | - open late 222 | C01NY9WC4P5: # sunroom 223 | - "Tea parties :tea:" # https://hackclub.slack.com/archives/C0158NY6QEN/p1615328859017200?thread_ts=1615328097.016700&cid=C0158NY6QEN 224 | C0146U2KVUK: # rishi's personal channel 225 | - ":ferrisbongo:" # https://hackclub.slack.com/archives/C0158NY6QEN/p1615340301017400?thread_ts=1615328097.016700&cid=C0158NY6QEN 226 | errors: 227 | userIsRestricted: Sorry, you need to be a full member of Hack Club in order to run this command. 228 | channelIsForbidden: Sorry, you can\'t start a meeting in this channel. 229 | emptyHostCode: Sorry, you need to give a meeting ID to run this command. -------------------------------------------------------------------------------- /dashboards/grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "editable": true, 19 | "fiscalYearStartMonth": 0, 20 | "graphTooltip": 0, 21 | "id": 1, 22 | "links": [], 23 | "liveNow": false, 24 | "panels": [ 25 | { 26 | "datasource": { 27 | "type": "graphite", 28 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 29 | }, 30 | "fieldConfig": { 31 | "defaults": { 32 | "color": { 33 | "mode": "palette-classic" 34 | }, 35 | "custom": { 36 | "axisCenteredZero": false, 37 | "axisColorMode": "text", 38 | "axisLabel": "", 39 | "axisPlacement": "auto", 40 | "barAlignment": 0, 41 | "drawStyle": "line", 42 | "fillOpacity": 0, 43 | "gradientMode": "none", 44 | "hideFrom": { 45 | "legend": false, 46 | "tooltip": false, 47 | "viz": false 48 | }, 49 | "lineInterpolation": "linear", 50 | "lineWidth": 1, 51 | "pointSize": 5, 52 | "scaleDistribution": { 53 | "type": "linear" 54 | }, 55 | "showPoints": "auto", 56 | "spanNulls": false, 57 | "stacking": { 58 | "group": "A", 59 | "mode": "none" 60 | }, 61 | "thresholdsStyle": { 62 | "mode": "off" 63 | } 64 | }, 65 | "mappings": [], 66 | "thresholds": { 67 | "mode": "absolute", 68 | "steps": [ 69 | { 70 | "color": "green", 71 | "value": null 72 | }, 73 | { 74 | "color": "red", 75 | "value": 80 76 | } 77 | ] 78 | }, 79 | "unit": "none" 80 | }, 81 | "overrides": [] 82 | }, 83 | "gridPos": { 84 | "h": 8, 85 | "w": 12, 86 | "x": 0, 87 | "y": 0 88 | }, 89 | "id": 1, 90 | "options": { 91 | "legend": { 92 | "calcs": [], 93 | "displayMode": "list", 94 | "placement": "bottom", 95 | "showLegend": true 96 | }, 97 | "tooltip": { 98 | "mode": "single", 99 | "sort": "none" 100 | } 101 | }, 102 | "targets": [ 103 | { 104 | "datasource": { 105 | "type": "graphite", 106 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 107 | }, 108 | "hide": false, 109 | "refCount": 0, 110 | "refId": "A", 111 | "target": "alias(stats.gauges.$env.slashz.hosts.total, 'total')" 112 | }, 113 | { 114 | "datasource": { 115 | "type": "graphite", 116 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 117 | }, 118 | "hide": false, 119 | "refCount": 0, 120 | "refId": "C", 121 | "target": "alias(stats.gauges.$env.slashz.hosts.open, 'open')" 122 | }, 123 | { 124 | "datasource": { 125 | "type": "graphite", 126 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 127 | }, 128 | "hide": false, 129 | "refCount": 0, 130 | "refId": "D", 131 | "target": "alias(diffSeries(#A, #C), 'used')", 132 | "targetFull": "alias(diffSeries(alias(stats.gauges.$env.slashz.hosts.total, 'total'), alias(stats.gauges.$env.slashz.hosts.open, 'open')), 'used')" 133 | } 134 | ], 135 | "title": "Zoom License Utilization (RAW)", 136 | "type": "timeseries" 137 | }, 138 | { 139 | "datasource": { 140 | "type": "graphite", 141 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 142 | }, 143 | "fieldConfig": { 144 | "defaults": { 145 | "color": { 146 | "mode": "palette-classic" 147 | }, 148 | "custom": { 149 | "axisCenteredZero": false, 150 | "axisColorMode": "text", 151 | "axisLabel": "", 152 | "axisPlacement": "auto", 153 | "barAlignment": 0, 154 | "drawStyle": "line", 155 | "fillOpacity": 0, 156 | "gradientMode": "none", 157 | "hideFrom": { 158 | "legend": false, 159 | "tooltip": false, 160 | "viz": false 161 | }, 162 | "lineInterpolation": "linear", 163 | "lineWidth": 1, 164 | "pointSize": 5, 165 | "scaleDistribution": { 166 | "type": "linear" 167 | }, 168 | "showPoints": "auto", 169 | "spanNulls": false, 170 | "stacking": { 171 | "group": "A", 172 | "mode": "none" 173 | }, 174 | "thresholdsStyle": { 175 | "mode": "off" 176 | } 177 | }, 178 | "mappings": [], 179 | "thresholds": { 180 | "mode": "absolute", 181 | "steps": [ 182 | { 183 | "color": "green", 184 | "value": null 185 | }, 186 | { 187 | "color": "red", 188 | "value": 80 189 | } 190 | ] 191 | }, 192 | "unit": "percent" 193 | }, 194 | "overrides": [] 195 | }, 196 | "gridPos": { 197 | "h": 8, 198 | "w": 12, 199 | "x": 12, 200 | "y": 0 201 | }, 202 | "id": 2, 203 | "options": { 204 | "legend": { 205 | "calcs": [], 206 | "displayMode": "list", 207 | "placement": "bottom", 208 | "showLegend": true 209 | }, 210 | "tooltip": { 211 | "mode": "single", 212 | "sort": "none" 213 | } 214 | }, 215 | "targets": [ 216 | { 217 | "datasource": { 218 | "type": "graphite", 219 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 220 | }, 221 | "hide": true, 222 | "refCount": 0, 223 | "refId": "A", 224 | "target": "alias(stats.gauges.$env.slashz.hosts.total, 'total')" 225 | }, 226 | { 227 | "datasource": { 228 | "type": "graphite", 229 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 230 | }, 231 | "hide": true, 232 | "refCount": 0, 233 | "refId": "C", 234 | "target": "alias(stats.gauges.$env.slashz.hosts.open, 'open')" 235 | }, 236 | { 237 | "datasource": { 238 | "type": "graphite", 239 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 240 | }, 241 | "hide": true, 242 | "refCount": 0, 243 | "refId": "D", 244 | "target": "alias(diffSeries(#A, #C), 'used')", 245 | "targetFull": "alias(diffSeries(alias(stats.gauges.$env.slashz.hosts.total, 'total'), alias(stats.gauges.$env.slashz.hosts.open, 'open')), 'used')" 246 | }, 247 | { 248 | "datasource": { 249 | "type": "graphite", 250 | "uid": "e332d031-491c-40ff-bdf0-c4ee3b48181a" 251 | }, 252 | "hide": false, 253 | "refCount": 0, 254 | "refId": "B", 255 | "target": "alias(asPercent(#D, #A), 'zoom license utilization %')", 256 | "targetFull": "alias(asPercent(alias(diffSeries(alias(stats.gauges.$env.slashz.hosts.total, 'total'), alias(stats.gauges.$env.slashz.hosts.open, 'open')), 'used'), alias(stats.gauges.$env.slashz.hosts.total, 'total')), 'zoom license utilization %')" 257 | } 258 | ], 259 | "title": "Zoom License Utilization (%)", 260 | "type": "timeseries" 261 | } 262 | ], 263 | "refresh": "5s", 264 | "schemaVersion": 38, 265 | "style": "dark", 266 | "tags": [], 267 | "templating": { 268 | "list": [ 269 | { 270 | "current": { 271 | "selected": true, 272 | "text": "production", 273 | "value": "production" 274 | }, 275 | "hide": 0, 276 | "includeAll": false, 277 | "label": "Environment", 278 | "multi": false, 279 | "name": "env", 280 | "options": [ 281 | { 282 | "selected": false, 283 | "text": "staging", 284 | "value": "staging" 285 | }, 286 | { 287 | "selected": true, 288 | "text": "production", 289 | "value": "production" 290 | } 291 | ], 292 | "query": "staging,production", 293 | "queryValue": "", 294 | "skipUrlSync": false, 295 | "type": "custom" 296 | } 297 | ] 298 | }, 299 | "time": { 300 | "from": "now-5m", 301 | "to": "now" 302 | }, 303 | "timepicker": {}, 304 | "timezone": "", 305 | "title": "Slash-Z", 306 | "uid": "f3869cb3-1e23-4ff2-84b1-74f9d10cf535", 307 | "version": 18, 308 | "weekStart": "" 309 | } -------------------------------------------------------------------------------- /scripts/zstory.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import psycopg 3 | from psycopg.cursor import Cursor 4 | import json 5 | from datetime import datetime 6 | import sys 7 | import os 8 | from slack_sdk import WebClient 9 | 10 | """ 11 | This tool helps get an overview or the "story" of a certain zoom call. 12 | 13 | Something like 14 | slash-z meeting schedule link created 15 | zoom created, using license 16 | participant 1 joined 17 | participant 2 joined 18 | participant 1 left 19 | participant 2 left 20 | zoom destroyed, license released 21 | 22 | Requirements: 23 | - meeting name (e.g yzu82) 24 | - schedulingLink id 25 | - get the license used by filtering for meetings using the schedulingLinkId in the Meeting table and look under ZoomID 26 | - also copy the meetingId 27 | * Might also be useful to get the time that event happened 28 | - get the events by looking into the WebhookEvent table using the meetingId copied 29 | - a zoom license is released when a zoom meeting is destroyed 30 | 31 | """ 32 | 33 | parser = argparse.ArgumentParser( 34 | prog="zstory", description="Helps you debug slash-z meetings" 35 | ) 36 | 37 | subparser = parser.add_subparsers(dest="command") 38 | 39 | dissector = subparser.add_parser( 40 | "dissect", 41 | description="Debug what happened in slash-z calls", 42 | ) 43 | filter_parser = subparser.add_parser( 44 | "filter", 45 | description="Filter meetings within a certain time range", 46 | ) 47 | 48 | dissector.add_argument( 49 | "meetid", 50 | default=None, 51 | help="The zoom schedule id e.g yzu4r in hack.af/z-join?id=yzu4r", 52 | ) # argument is zoomID or call link name 53 | dissector.add_argument( 54 | "-z", 55 | action="store_true", 56 | help="If provided, then is considered to be the zoom meeting id e.g 88934609083", 57 | ) # if present, will return the single zoom call 58 | dissector.add_argument( 59 | "--start", 60 | type=int, 61 | help="Specify the start time when searching for a meeting in a range", 62 | ) # specifies a starting point of the search 63 | dissector.add_argument( 64 | "--end", 65 | type=int, 66 | help="Specify the latest time when searching for a meeting in a range", 67 | ) # specifies a stopping point 68 | 69 | # filter arguments 70 | filter_parser.add_argument( 71 | "--start", 72 | required=True, 73 | type=int, 74 | help="Specify the start time when searching for a meeting in a range", 75 | ) # specifies a starting point of the search 76 | filter_parser.add_argument( 77 | "--end", 78 | type=int, 79 | help="Specify the latest time when searching for a meeting in a range", 80 | ) # specifies a stopping point 81 | 82 | # parse args 83 | args = parser.parse_args() 84 | if not len(sys.argv) > 1: 85 | parser.print_help() 86 | quit() 87 | 88 | slack_client = WebClient(os.environ["SLACK_USER_OAUTH_TOKEN"]) 89 | 90 | # meeting passed here is of the form 91 | # (meeting_id, start_time, end_time) 92 | def check_overlap(meet_1: (str, int, int), meet_2: (str, int, int)): 93 | meet1 = range(meet_1[1], meet_1[2]) 94 | meet2 = range(meet_2[1], meet_2[2]) 95 | overlap = list(set(meet2).intersection(meet1)) 96 | if len(overlap) > 0: 97 | return overlap[-1] - overlap[0] 98 | return 0 99 | 100 | 101 | def trace_events(cursor: Cursor, meetingId: str): 102 | # query the WebHook events of the meeting 103 | cursor.execute( 104 | 'SELECT (timestamp, "rawData") FROM "WebhookEvent" WHERE "meetingId"=%s ORDER BY timestamp ASC', 105 | (meetingId,), 106 | ) 107 | events = cursor.fetchall() 108 | 109 | if len(events) == 0: 110 | print("No WebhookEvents for this meeting") 111 | return 112 | 113 | # will be replaced with the timestamp of the first webhook event 114 | start_time = datetime.now() 115 | meetings = [] 116 | for item in events: 117 | timestamp, event = item[0] 118 | event_dict = json.loads(event) 119 | event_type = event_dict["event"] 120 | 121 | time = datetime.fromisoformat(timestamp) 122 | if event_type == "meeting.started": 123 | 124 | for meeting in meetings: 125 | # in the situation where there are two meetings that were started using the same zoom_id 126 | # the ended_at time of the meeting is usually the same for them 127 | now = datetime.now() 128 | overlap = check_overlap( 129 | (meetingId, int(meeting.timestamp()), int(now.timestamp())), 130 | (meetingId, int(meeting.timestamp()), int(now.timestamp())), 131 | ) 132 | if overlap > 0: 133 | print(f"\033[93mOverlap with ({meetingId}) by {overlap} seconds\033[0;0m") 134 | 135 | start_time = time 136 | meetings.append(start_time) 137 | 138 | participant = event_dict["payload"]["object"].get("participant", None) 139 | 140 | formattted_time = time.strftime('%Y-%m-%d %H:%M:%S%z') 141 | participant_name = participant['user_name'] if participant else " " 142 | print( 143 | f"{formattted_time:>25} | {(time - start_time).seconds:5}s | {participant_name:15} | {event_type:6}" 144 | ) 145 | 146 | 147 | 148 | def filter_by_date(cursor: Cursor, start: int, end: int | None): 149 | query_no_end = 'SELECT ("zoomID", "startedAt", "endedAt") FROM "Meeting" WHERE "startedAt">=%s ORDER BY "startedAt" ASC' 150 | query_with_end = 'SELECT ("zoomID", "startedAt", "endedAt") FROM "Meeting" WHERE "startedAt">=%s AND "startedAt"<=%s ORDER BY "startedAt" ASC' 151 | 152 | if end is not None: 153 | cursor.execute( 154 | query_with_end, (datetime.fromtimestamp(start), datetime.fromtimestamp(end)) 155 | ) 156 | else: 157 | cursor.execute(query_no_end, (datetime.fromtimestamp(start),)) 158 | 159 | meetings = cursor.fetchall() 160 | 161 | print(f"Found {len(meetings)} meetings") 162 | 163 | prev_meetings = [] 164 | for idx, meeting in enumerate(meetings): 165 | overlap = 0 166 | 167 | zoom_id, started_at, ended_at = meeting[0] 168 | 169 | started_at = datetime.fromisoformat(started_at) 170 | ended_at = datetime.fromisoformat(ended_at) if ended_at else None 171 | 172 | if ended_at is not None: 173 | _meeting = (zoom_id, int(started_at.timestamp()), int(ended_at.timestamp())) 174 | # check for overlapping meetings 175 | for p_meeting in prev_meetings: 176 | # print("p_meeting = ", p_meeting) 177 | # print("curr meeting = ", _meeting) 178 | overlap = check_overlap(p_meeting, _meeting) 179 | 180 | if overlap > 0: 181 | print( 182 | f"\033[93mOverlap ({p_meeting[0]}) by {overlap} seconds \033[0;0m" 183 | ) 184 | 185 | prev_meetings.append(_meeting) 186 | time_elapsed = ended_at - started_at 187 | print( 188 | f"zoomID: {zoom_id} | started {started_at} ended {time_elapsed.seconds}s later" 189 | ) 190 | 191 | else: 192 | print(f"zoomID: {zoom_id} | started {ended_at} Ongoing... ") 193 | print() 194 | 195 | 196 | def dissect_scheduled_meeting(cursor: Cursor, meetid: str, start, end): 197 | print(f"Tracing meetings with name {meetid}...") 198 | 199 | # get the scheduling link id 200 | cursor.execute('SELECT id FROM "SchedulingLink" WHERE name=%s', (meetid,)) 201 | 202 | schedule = cursor.fetchone() 203 | scheduling_link_id = schedule[0] if schedule else None 204 | 205 | if scheduling_link_id is None: 206 | print(f"Scheduling link with name {meetid} does not exist") 207 | quit() 208 | 209 | queries = { 210 | "normal": 'SELECT (id, "zoomID", "startedAt", "endedAt", "joinURL", "creatorSlackID") FROM "Meeting" WHERE "schedulingLinkId"=%s', 211 | "start": 'SELECT (id, "zoomID", "startedAt", "endedAt", "joinURL", "creatorSlackID") FROM "Meeting" WHERE "schedulingLinkId"=%s AND "startedAt">=%s ORDER BY "startedAt" ASC', 212 | "end": 'SELECT (id, "zoomID", "startedAt", "endedAt", "joinURL", "creatorSlackID") FROM "Meeting" WHERE "schedulingLinkId"=%s AND "startedAt">=%s AND "startedAt"<=%s ORDER BY "startedAt" ASC', 213 | } 214 | # query meeting id and zoom license 215 | if start and end: 216 | cursor.execute( 217 | queries.get("end"), 218 | ( 219 | scheduling_link_id, 220 | datetime.fromtimestamp(start), 221 | datetime.fromtimestamp(end), 222 | ), 223 | ) 224 | elif start and end is None: 225 | cursor.execute( 226 | queries.get("start"), (scheduling_link_id, datetime.fromtimestamp(start)) 227 | ) 228 | else: 229 | cursor.execute(queries.get("normal"), (scheduling_link_id,)) 230 | 231 | meetings = cursor.fetchall() 232 | 233 | prev_meetings = [] 234 | print(f"\n{len(meetings)} meetings found") 235 | for idx, meeting in enumerate(meetings): 236 | overlap = 0 237 | # zoomId also refers to the zoom license 238 | meetingId, zoomId, started_at, ended_at, join_url, creator_slack_id = meeting[0] 239 | started_at = datetime.fromisoformat(started_at) 240 | ended_at = datetime.fromisoformat(ended_at) if ended_at else None 241 | 242 | _meeting = (meetingId, started_at, ended_at) 243 | 244 | slack_name = slack_client.users_info(user=creator_slack_id).get("user")["name"] 245 | 246 | 247 | print(f"\nMEETING #{idx+1} (ID = {meetingId})") 248 | print(f"CREATOR SLACK ID = {creator_slack_id} && NAME = @{slack_name}") 249 | __meeting = (_meeting[0], int(_meeting[1].timestamp()), int(_meeting[2].timestamp())) 250 | if ended_at is not None: 251 | # check for overlapping meetings 252 | for p_meeting in prev_meetings: 253 | # __meeting has datetime object as int timestamps 254 | overlap = check_overlap(p_meeting, __meeting) 255 | 256 | if overlap > 0: 257 | print( 258 | f"\033[93m WARNING! This meeting overlaps with ({p_meeting[0]}) by {overlap} seconds \033[0;0m" 259 | ) 260 | 261 | prev_meetings.append(__meeting) 262 | print(f"{'LICENSE LOCK':>16} ({zoomId}) @ {started_at}") 263 | print(f"{'JOIN LINK:':>14} {join_url}") 264 | print(f"{'EVENT LOG:':>14}") 265 | trace_events(cursor, meetingId) 266 | prev_meeting = (meetingId, started_at) 267 | print(f"{'END OF EVENT LOG':>20}") 268 | if ended_at is None: 269 | ansi_red = "\u001b[31m" 270 | ansi_endc = "\033[0m" 271 | print(f"{ansi_red}LICENSE STILL IN USE{ansi_endc}") 272 | else: 273 | print(f"{'LICENSE UNLOCKED':>18} @ {ended_at}") 274 | 275 | def dissect_slack_meeting(cursor: Cursor, zoom_id: str): 276 | cursor.execute('SELECT (id, "startedAt", "endedAt", "joinURL", "creatorSlackID") FROM "Meeting" WHERE "zoomID"=%s', (args.meetid,)) # type: ignore 277 | meeting = cursor.fetchone() 278 | meeting_id, started_at, ended_at, join_url, creator_slack_id = meeting[0] if meeting else (None, None, None, None, None) 279 | 280 | if meeting_id is None: 281 | print(f"Could not find meeting with zoom ID {zoom_id}") 282 | quit() 283 | 284 | started_at = datetime.fromisoformat(started_at) 285 | ended_at = datetime.fromisoformat(ended_at) if ended_at else None 286 | 287 | slack_name = slack_client.users_info(user=creator_slack_id).get("user")["name"] 288 | 289 | print(f"\nMEETING (ID = {meeting_id})") 290 | print(f"CREATOR SLACK ID = {creator_slack_id} && NAME = @{slack_name}") 291 | print(f"{'LICENSE LOCK':>16} ({zoom_id}) @ {started_at}") 292 | print(f"{'JOIN LINK:':>14} {join_url}") 293 | print(f"{'EVENT LOG:':>14}") 294 | trace_events(cursor, meeting_id) 295 | print(f"{'END OF EVENT LOG':>20}") 296 | print(f"{'LICENSE UNLOCK':>18} {zoom_id} @ {ended_at}") 297 | 298 | 299 | # connect to the database 300 | databaseUrl = os.environ['DATABASE_URL'] 301 | with psycopg.connect(databaseUrl) as conn: 302 | # open cursor to perform database operations 303 | with conn.cursor() as cursor: 304 | match args.command: 305 | case "dissect": 306 | if args.meetid and args.z: 307 | dissect_slack_meeting(cursor, args.meetid) 308 | quit() 309 | 310 | if args.meetid and not args.z: 311 | dissect_scheduled_meeting(cursor, args.meetid, args.start, args.end) 312 | quit() 313 | case "filter": 314 | filter_by_date(cursor, args.start, args.end) 315 | quit() 316 | 317 | conn.commit() 318 | -------------------------------------------------------------------------------- /public/tos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Slash Z - Terms of Service 6 | 7 | 8 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Hack Club 50 | 51 |
52 |

Slash Z

53 |

Terms of Service

54 |
55 | 56 |
57 |

Last updated: February 24, 2021

58 |

Please read these terms and conditions carefully before using Our Service.

59 |

Interpretation and Definitions

60 |

Interpretation

61 |

The words of which the initial letter is capitalized have meanings defined under the following conditions. The 62 | following definitions shall have the same meaning regardless of whether they appear in singular or in plural.

63 |

Definitions

64 |

For the purposes of these Terms and Conditions:

65 |
    66 |
  • 67 |

    Affiliate means an entity that controls, is controlled by or is under common control with a 68 | party, where "control" means ownership of 50% or more of the shares, equity interest or other securities 69 | entitled to vote for election of directors or other managing authority.

    70 |
  • 71 |
  • 72 |

    Country refers to: California, United States

    73 |
  • 74 |
  • 75 |

    Company (referred to as either "the Company", "We", "Us" or 76 | "Our" in this Agreement) refers to The Hack Foundation d.b.a Hack Club, 8605 Santa Monica Blvd #86294 West Hollywood, CA 90069.

    77 |
  • 78 |
  • 79 |

    Device means any device that can access the Service such as a computer, a cellphone or a digital 80 | tablet.

    81 |
  • 82 |
  • 83 |

    Service refers to the Addon.

    84 |
  • 85 |
  • 86 |

    Terms and Conditions (also referred as "Terms") mean these Terms and Conditions that 87 | form the entire agreement between You and the Company regarding the use of the Service. This Terms and Conditions 88 | agreement has been created with the help of the Terms and Conditions Generator.

    90 |
  • 91 |
  • 92 |

    Third-party Social Media Service means any services or content (including data, information, 93 | products or services) provided by a third-party that may be displayed, included or made available by the Service. 94 |

    95 |
  • 96 |
  • 97 |

    Addon refers to /z, accessible from https://calendar.google.com

    99 |
  • 100 |
  • 101 |

    You means the individual accessing or using the Service, or the company, or other legal entity 102 | on behalf of which such individual is accessing or using the Service, as applicable.

    103 |
  • 104 |
105 |

Acknowledgment

106 |

These are the Terms and Conditions governing the use of this Service and the agreement that operates between You and 107 | the Company. These Terms and Conditions set out the rights and obligations of all users regarding the use of the 108 | Service.

109 |

Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and 110 | Conditions. These Terms and Conditions apply to all visitors, users and others who access or use the Service.

111 |

By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part 112 | of these Terms and Conditions then You may not access the Service.

113 |

You represent that you are over the age of 18. The Company does not permit those under 18 to use the Service.

114 |

Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure 115 | of Your personal information when You use the Application or the Website and tells You about Your privacy rights and 116 | how the law protects You. Please read Our Privacy Policy carefully before using Our Service.

117 |

Links to Other Websites

118 |

Our Service may contain links to third-party web sites or services that are not owned or controlled by the Company. 119 |

120 |

The Company has no control over, and assumes no responsibility for, the content, privacy policies, or practices of 121 | any third party web sites or services. You further acknowledge and agree that the Company shall not be responsible or 122 | liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use 123 | of or reliance on any such content, goods or services available on or through any such web sites or services.

124 |

We strongly advise You to read the terms and conditions and privacy policies of any third-party web sites or services 125 | that You visit.

126 |

Termination

127 |

We may terminate or suspend Your access immediately, without prior notice or liability, for any reason whatsoever, 128 | including without limitation if You breach these Terms and Conditions.

129 |

Upon termination, Your right to use the Service will cease immediately.

130 |

Limitation of Liability

131 |

Notwithstanding any damages that You might incur, the entire liability of the Company and any of its suppliers under 132 | any provision of this Terms and Your exclusive remedy for all of the foregoing shall be limited to the amount actually 133 | paid by You through the Service or 100 USD if You haven't purchased anything through the Service.

134 |

To the maximum extent permitted by applicable law, in no event shall the Company or its suppliers be liable for any 135 | special, incidental, indirect, or consequential damages whatsoever (including, but not limited to, damages for loss of 136 | profits, loss of data or other information, for business interruption, for personal injury, loss of privacy arising 137 | out of or in any way related to the use of or inability to use the Service, third-party software and/or third-party 138 | hardware used with the Service, or otherwise in connection with any provision of this Terms), even if the Company or 139 | any supplier has been advised of the possibility of such damages and even if the remedy fails of its essential 140 | purpose.

141 |

Some states do not allow the exclusion of implied warranties or limitation of liability for incidental or 142 | consequential damages, which means that some of the above limitations may not apply. In these states, each party's 143 | liability will be limited to the greatest extent permitted by law.

144 |

"AS IS" and "AS AVAILABLE" Disclaimer

145 |

The Service is provided to You "AS IS" and "AS AVAILABLE" and with all faults and defects without 146 | warranty of any kind. To the maximum extent permitted under applicable law, the Company, on its own behalf and on 147 | behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all 148 | warranties, whether express, implied, statutory or otherwise, with respect to the Service, including all implied 149 | warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may 150 | arise out of course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, 151 | the Company provides no warranty or undertaking, and makes no representation of any kind that the Service will meet 152 | Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems 153 | or services, operate without interruption, meet any performance or reliability standards or be error free or that any 154 | errors or defects can or will be corrected.

155 |

Without limiting the foregoing, neither the Company nor any of the company's provider makes any representation or 156 | warranty of any kind, express or implied: (i) as to the operation or availability of the Service, or the information, 157 | content, and materials or products included thereon; (ii) that the Service will be uninterrupted or error-free; (iii) 158 | as to the accuracy, reliability, or currency of any information or content provided through the Service; or (iv) that 159 | the Service, its servers, the content, or e-mails sent from or on behalf of the Company are free of viruses, scripts, 160 | trojan horses, worms, malware, timebombs or other harmful components.

161 |

Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory 162 | rights of a consumer, so some or all of the above exclusions and limitations may not apply to You. But in such a case 163 | the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under 164 | applicable law.

165 |

Governing Law

166 |

The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. 167 | Your use of the Application may also be subject to other local, state, national, or international laws.

168 |

Disputes Resolution

169 |

If You have any concern or dispute about the Service, You agree to first try to resolve the dispute informally by 170 | contacting the Company.

171 |

For European Union (EU) Users

172 |

If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in 173 | which you are resident in.

174 |

United States Legal Compliance

175 |

You represent and warrant that (i) You are not located in a country that is subject to the United States government 176 | embargo, or that has been designated by the United States government as a "terrorist supporting" country, 177 | and (ii) You are not listed on any United States government list of prohibited or restricted parties.

178 |

Severability and Waiver

179 |

Severability

180 |

If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and 181 | interpreted to accomplish the objectives of such provision to the greatest extent possible under applicable law and 182 | the remaining provisions will continue in full force and effect.

183 |

Waiver

184 |

Except as provided herein, the failure to exercise a right or to require performance of an obligation under this 185 | Terms shall not effect a party's ability to exercise such right or require such performance at any time thereafter nor 186 | shall be the waiver of a breach constitute a waiver of any subsequent breach.

187 |

Translation Interpretation

188 |

These Terms and Conditions may have been translated if We have made them available to You on our Service. 189 | You agree that the original English text shall prevail in the case of a dispute.

190 |

Changes to These Terms and Conditions

191 |

We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. If a revision is material 192 | We will make reasonable efforts to provide at least 30 days' notice prior to any new terms taking effect. What 193 | constitutes a material change will be determined at Our sole discretion.

194 |

By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the 195 | revised terms. If You do not agree to the new terms, in whole or in part, please stop using the website and the 196 | Service.

197 |

Contact Us

198 |

If you have any questions about these Terms and Conditions, You can contact us:

199 |
    200 |
  • 201 |

    By email: slash-z@hackclub.com

    202 |
  • 203 |
  • 204 |

    By mail: The Hack Foundation d.b.a Hack Club, 8605 Santa Monica Blvd #86294 West Hollywood, CA 90069

    205 |
  • 206 |
207 |

Home | Privacy Policy | Support

208 |
209 | 210 | 215 | 218 | 219 | -------------------------------------------------------------------------------- /public/auth-start.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 206 | 207 | 208 | 209 |
210 |
211 |

Continue with...

212 | 213 | 214 |
215 |
216 | 217 | 229 |
230 |
231 |
232 |

Guest Book of Orpheus Manor

233 |

To Whom It May Concern,

234 |

If you are about to sign this guest book, make sure you are prepared.

235 |

You will require, this guestbook & fountain pen, an understanding of the contract you are about to sign, and 236 | a sacrifice.

237 |

238 | While anyone is permitted onto the grounds of Orpheus Manor, only Hack Clubbers are permitted to open rooms up 239 | for meetings. 240 | Placing your name in this book will create a bond between souls - tethering your Slack User ID to your Gmail 241 | address. 242 | The binding process is can be undone upon request, but is used to verify you truly are a Hack Clubber before 243 | opening a room. 244 | You only need to sign here once. 245 |

246 |

Sincerely,

247 |

The Groundskeeper

248 |
249 |
250 |

OAuth permissions

251 |

252 | 👋 Hey there whoever-you-are!

253 |

254 | This sign-in will give us access to your email address & Slack ID. 255 | We're only providing /z to Hack Club Slack users, so we link these 256 | in our database to ensure only Hack Clubbers can use our calendar integration. 257 |

258 |

259 | I don't use the addresses to send you emails except for admin/debugging tasks (ie. if I need to reach out 260 | to you to fix your account). 261 |

262 |

Just reach out over email or Slack if you have any questions,

263 |

Max Wofford (the developer)

264 |
265 | 266 | 267 |
268 | 269 | 270 | 321 | 322 | 611 | 612 | 613 | --------------------------------------------------------------------------------