├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── LICENSE ├── bot ├── classes │ ├── api.ts │ ├── database.ts │ ├── discord.ts │ ├── hyperbeam.ts │ ├── room.ts │ └── sessions.ts ├── commands │ ├── start.ts │ ├── stats.ts │ └── stop.ts ├── index.ts ├── prisma │ ├── migrations │ │ ├── 20220731062710_initial_migration │ │ │ └── migration.sql │ │ ├── 20220913184207_change_model │ │ │ └── migration.sql │ │ ├── 20220917074506_track_item_updates │ │ │ └── migration.sql │ │ ├── 20221004205946_store_region │ │ │ └── migration.sql │ │ ├── 20221105131227_add_discord_ids │ │ │ └── migration.sql │ │ ├── 20221107220256_get_session_feedback │ │ │ └── migration.sql │ │ ├── 20230222183204_ │ │ │ └── migration.sql │ │ ├── 20230510193702_updated_discriminator_to_be_optional │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── schemas │ ├── cursor.ts │ ├── member.ts │ └── room.ts ├── tsconfig.json ├── types.d.ts └── utils │ ├── color.ts │ ├── inviteUrl.ts │ ├── sanitize.ts │ └── tokenHandler.ts ├── client ├── index.html ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── src │ ├── App.svelte │ ├── assets │ │ ├── demo.mp4 │ │ └── scss │ │ │ ├── _reset.scss │ │ │ ├── _variables.scss │ │ │ └── main.scss │ ├── components │ │ ├── Avatar.svelte │ │ ├── Cursor.svelte │ │ ├── ErrorPage.svelte │ │ ├── Hyperbeam.svelte │ │ ├── IconButton.svelte │ │ ├── Invite.svelte │ │ ├── Loading.svelte │ │ ├── Members.svelte │ │ ├── Toolbar.svelte │ │ ├── Tooltip.svelte │ │ └── Volume.svelte │ ├── main.ts │ ├── pages │ │ ├── Authorize.svelte │ │ ├── Lander.svelte │ │ └── Room.svelte │ ├── schemas │ │ ├── cursor.ts │ │ ├── member.ts │ │ └── room.ts │ ├── scripts │ │ └── api.ts │ ├── store.ts │ └── types.d.ts └── tsconfig.json ├── env.d.ts ├── package-lock.json ├── package.json ├── pm2.config.js ├── readme.md ├── scripts ├── clearInactiveUsers.ts └── syncSchemas.ts ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_CLIENT_ID=422242224222442424 2 | DISCORD_CLIENT_SECRET="rrceetetsreeersrceereccerrcscret" 3 | DISCORD_BOT_TOKEN="etotkeoeonoetnkeoeteeketoettkknnenkknooonoknnektoeenneokketnnonoeektet" 4 | VITE_DISCORD_SUPPORT_SERVER="https://discord.gg/D78RsGfQjq" 5 | VITE_GITHUB_URL="https://github.com/hyperbeam/discord-bot" 6 | HB_API_KEY="kykeyeykykekeyykeyyyekekyyyyeyyykekykeyekyk" 7 | HB_API_ENV="testing" 8 | VITE_CLIENT_ID=444224444244222444 9 | VITE_CLIENT_PORT=4000 10 | VITE_CLIENT_BASE_URL="http://localhost:4000" 11 | VITE_API_SERVER_PORT=3000 12 | VITE_API_SERVER_BASE_URL="http://localhost:3000" 13 | DATABASE_URL="file:../../database.db" 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:svelte/recommended"], 8 | "ignorePatterns": ["dist/**/*"], 9 | "globals": { 10 | "NodeJS": true, 11 | "BigInt": true 12 | }, 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": 11, 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["@typescript-eslint", "simple-import-sort"], 19 | "rules": { 20 | "no-cond-assign": [2, "except-parens"], 21 | "no-unused-vars": "off", 22 | "@typescript-eslint/no-unused-vars": 1, 23 | "no-empty": [ 24 | "error", 25 | { 26 | "allowEmptyCatch": true 27 | } 28 | ], 29 | "prefer-const": [ 30 | "warn", 31 | { 32 | "destructuring": "all" 33 | } 34 | ], 35 | "no-console": "off", 36 | "no-constant-condition": "off", 37 | "no-empty-pattern": "off", 38 | "no-return-await": "warn", 39 | "no-unneeded-ternary": "warn", 40 | "object-shorthand": ["warn", "always"], 41 | "simple-import-sort/imports": "warn", 42 | "simple-import-sort/exports": "warn" 43 | }, 44 | "overrides": [ 45 | { 46 | "files": ["./client/**/*.ts", "./client/**/*.d.ts"], 47 | "env": { 48 | "browser": true 49 | } 50 | }, 51 | { 52 | "files": ["./bot/**/*.ts", "./bot/**/*.d.ts"], 53 | "env": { 54 | "browser": false, 55 | "node": true 56 | } 57 | }, 58 | { 59 | "files": ["./client/*.svelte", "./client/**/*.svelte"], 60 | "parser": "svelte-eslint-parser", 61 | "parserOptions": { 62 | "parser": "@typescript-eslint/parser", 63 | "parserOptions": { 64 | "project": "./client/tsconfig.json", 65 | "extraFileExtensions": [".svelte"] 66 | } 67 | }, 68 | "env": { 69 | "es6": true, 70 | "browser": true 71 | }, 72 | "extends": ["plugin:svelte/recommended"], 73 | "rules": { 74 | "simple-import-sort/imports": "warn", 75 | "simple-import-sort/exports": "warn" 76 | } 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | config.json 132 | 133 | # Miscellaneous 134 | .tmp/ 135 | .vscode/ 136 | .env 137 | dist/ 138 | 139 | # Database 140 | database.db 141 | database.db-journal 142 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "htmlWhitespaceSensitivity": "ignore", 4 | "semi": true, 5 | "trailingComma": "all", 6 | "tabWidth": 2, 7 | "singleQuote": false, 8 | "bracketSameLine": true, 9 | "printWidth": 120 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2022 Porasjeet Singh 2 | 3 | Permission is hereby granted, 4 | free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /bot/classes/api.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketTransport } from "@colyseus/ws-transport"; 2 | import { Server } from "colyseus"; 3 | import cors from "cors"; 4 | import express, { Express } from "express"; 5 | import { createServer } from "http"; 6 | import morgan from "morgan"; 7 | import { networkInterfaces } from "os"; 8 | 9 | import db from "./database"; 10 | import { authorize } from "./discord"; 11 | import { AuthenticatedClient, BotRoom } from "./room"; 12 | 13 | const defaultAddresses = Object.values(networkInterfaces()) 14 | .flatMap((nInterface) => nInterface ?? []) 15 | .filter( 16 | (detail) => 17 | detail && 18 | detail.address && 19 | // Node < v18 20 | ((typeof detail.family === "string" && detail.family === "IPv4") || 21 | // Node >= v18 22 | (typeof detail.family === "number" && detail.family === 4)), 23 | ) 24 | .map((detail) => detail.address); 25 | 26 | if (!defaultAddresses.includes("localhost")) defaultAddresses.push("localhost"); 27 | 28 | const protocols = ["http", "https", "ws", "wss"]; 29 | const addresses: string[] = []; 30 | defaultAddresses.forEach((address) => { 31 | protocols.forEach((protocol) => addresses.push(`${protocol}://${address}:${process.env.VITE_CLIENT_PORT}`)); 32 | }); 33 | 34 | const app: Express = express(); 35 | 36 | app.use(morgan("dev")); 37 | app.use(express.json()); 38 | app.use( 39 | cors({ 40 | origin: addresses, 41 | methods: ["GET", "POST", "DELETE", "PUT", "OPTIONS"], 42 | allowedHeaders: ["Content-Type", "Authorization", "Origin"], 43 | credentials: true, 44 | }), 45 | ); 46 | 47 | app.get("/authorize/:code", async (req, res) => { 48 | if (!req.params.code) return res.status(400).send({ error: "No code provided" }); 49 | try { 50 | const data = await authorize(req.params.code); 51 | if (data) return res.status(200).send(data); 52 | } catch (err) { 53 | return res.status(500).send({ error: "Could not authorize user." }); 54 | } 55 | }); 56 | 57 | app.get("/info/:url", async (req, res) => { 58 | if (!req.params.url) return res.status(400).send({ error: "No url provided" }); 59 | const room = await db.session.findUnique({ 60 | where: { url: req.params.url }, 61 | select: { 62 | createdAt: true, 63 | endedAt: true, 64 | owner: { 65 | select: { 66 | username: true, 67 | discriminator: true, 68 | }, 69 | }, 70 | }, 71 | }); 72 | if (!room) return res.status(404).send({ error: "Room not found" }); 73 | if (room.endedAt) { 74 | const lastDay = Date.now() - 1000 * 60 * 60 * 24; 75 | if (room.endedAt.getTime() <= lastDay) { 76 | return res.status(404).send({ error: "Room not found" }); 77 | } 78 | } 79 | return res.status(200).send({ 80 | ...room, 81 | active: !room.endedAt, 82 | }); 83 | }); 84 | 85 | const server = new Server({ 86 | transport: new WebSocketTransport({ 87 | server: createServer(app), 88 | }), 89 | gracefullyShutdown: false, 90 | }); 91 | 92 | server 93 | .define("room", BotRoom) 94 | .on("create", (room: BotRoom) => { 95 | console.log(`Room ${room.roomId} created by user ${room.state.ownerId}.`); 96 | }) 97 | .on("dispose", (room: BotRoom) => { 98 | console.log(`Room ${room.roomId} disposed.`); 99 | }) 100 | .on("join", (room: BotRoom, client: AuthenticatedClient) => { 101 | console.log(`${client.userData.name} (${client.userData.id} - ${client.sessionId}) joined room ${room.roomId}`); 102 | }) 103 | .on("leave", (room: BotRoom, client: AuthenticatedClient) => { 104 | console.log(`${client.userData.name} (${client.userData.id} - ${client.sessionId}) left room ${room.roomId}`); 105 | }); 106 | 107 | export default server; 108 | -------------------------------------------------------------------------------- /bot/classes/database.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const database = new PrismaClient(); 4 | 5 | export default database; 6 | -------------------------------------------------------------------------------- /bot/classes/discord.ts: -------------------------------------------------------------------------------- 1 | import Discord from "discord-oauth2"; 2 | import { nanoid } from "nanoid"; 3 | import fetch from "node-fetch"; 4 | import { User as BotUser } from "slash-create"; 5 | 6 | import TokenHandler from "../utils/tokenHandler"; 7 | import db from "./database"; 8 | 9 | // make sure you set the redirect uri to the same url as the one in the discord app 10 | const discord = new Discord({ 11 | clientId: process.env.DISCORD_CLIENT_ID, 12 | clientSecret: process.env.DISCORD_CLIENT_SECRET, 13 | redirectUri: process.env.VITE_CLIENT_BASE_URL + "/authorize", 14 | }); 15 | 16 | type BasicUser = { 17 | id: string; 18 | avatar: string | null; 19 | username: string; 20 | discriminator: string | null; 21 | }; 22 | 23 | type AuthorizedUserData = BasicUser & { token: string }; 24 | 25 | export async function authorize(code: string): Promise { 26 | const authorizationData = await fetch("https://discordapp.com/api/oauth2/token", { 27 | method: "POST", 28 | headers: { 29 | "Content-Type": "application/x-www-form-urlencoded", 30 | }, 31 | body: new URLSearchParams({ 32 | grant_type: "authorization_code", 33 | code, 34 | redirect_uri: process.env.VITE_CLIENT_BASE_URL! + "/authorize", 35 | client_id: process.env.DISCORD_CLIENT_ID!, 36 | client_secret: process.env.DISCORD_CLIENT_SECRET!, 37 | }).toString(), 38 | }).then((response) => response.json() as unknown as Discord.TokenRequestResult); 39 | 40 | // we should get back an access and refresh token 41 | if (!authorizationData.access_token || !authorizationData.refresh_token) throw new Error("Could not authorize user"); 42 | 43 | // get the user info from the discord api 44 | const user = await discord.getUser(authorizationData.access_token); 45 | if (!user) throw new Error("Invalid user"); 46 | 47 | const existingUser = await db.user.findFirst({ select: { hash: true }, where: { id: user.id } }); 48 | const hash = existingUser?.hash || nanoid(); 49 | 50 | const userData = { 51 | id: user.id, 52 | username: user.username, 53 | discriminator: user.discriminator, 54 | avatar: user.avatar, 55 | email: user.email, 56 | refreshToken: authorizationData.refresh_token, 57 | accessToken: authorizationData.access_token, 58 | hash, 59 | }; 60 | 61 | const dbUser = await db.user.upsert({ 62 | where: { 63 | id: user.id, 64 | }, 65 | create: userData, 66 | update: userData, 67 | select: { 68 | id: true, 69 | username: true, 70 | discriminator: true, 71 | avatar: true, 72 | email: false, 73 | refreshToken: false, 74 | accessToken: false, 75 | hash: false, 76 | }, 77 | }); 78 | 79 | const token = TokenHandler.generate(dbUser.id, userData.hash); 80 | return { ...dbUser, token }; 81 | } 82 | 83 | export async function updateUser(user: BotUser): Promise { 84 | return db.user.upsert({ 85 | where: { 86 | id: user.id, 87 | }, 88 | create: { 89 | id: user.id, 90 | username: user.username, 91 | discriminator: user.discriminator, 92 | avatar: user.avatar, 93 | }, 94 | update: { 95 | username: user.username, 96 | discriminator: user.discriminator, 97 | avatar: user.avatar, 98 | }, 99 | select: { 100 | id: true, 101 | username: true, 102 | discriminator: true, 103 | avatar: true, 104 | email: false, 105 | refreshToken: false, 106 | accessToken: false, 107 | hash: false, 108 | }, 109 | }); 110 | } 111 | 112 | export default { 113 | discord, 114 | authorize, 115 | updateUser, 116 | }; 117 | -------------------------------------------------------------------------------- /bot/classes/hyperbeam.ts: -------------------------------------------------------------------------------- 1 | import { PermissionData } from "@hyperbeam/web"; 2 | import fetch from "node-fetch"; 3 | 4 | export type VMResponse = { 5 | session_id: string; 6 | embed_url: string; 7 | admin_token: string; 8 | termination_date?: string | null; 9 | }; 10 | 11 | export type VMRequestBody = { 12 | start_url?: string; 13 | kiosk?: boolean; 14 | offline_timeout?: number | null; 15 | control_disable_default?: boolean; 16 | region?: "NA" | "EU" | "AS"; 17 | profile?: { load?: string; save?: boolean }; 18 | ublock?: boolean; 19 | extension?: { field: string }; 20 | webgl?: boolean; 21 | width?: number; 22 | height?: number; 23 | fps?: number; 24 | hide_cursor?: boolean; 25 | nsfw?: boolean; 26 | }; 27 | 28 | type RequestInit = Exclude[1], undefined>; 29 | 30 | type RequestProps = { 31 | path: string; 32 | method: Required; 33 | baseUrl: string; 34 | headers?: RequestInit["headers"]; 35 | authorization: string; 36 | returnRawResponse?: boolean; 37 | }; 38 | 39 | async function HbFetch( 40 | props: RequestProps & { body?: RequestBody }, 41 | ): Promise { 42 | const headers = { Authorization: `Bearer ${props.authorization}` }; 43 | const response = await fetch(`${props.baseUrl}${props.path}`, { 44 | method: props.method, 45 | headers: { ...headers, ...(props.headers || {}) }, 46 | body: props.body ? JSON.stringify(props.body) : undefined, 47 | }); 48 | if (props.returnRawResponse) return response as unknown as ResponseType; 49 | else { 50 | let result; 51 | try { 52 | result = await response.json(); 53 | } catch (e) { 54 | result = await response.text(); 55 | throw new Error(`Failed to parse response as JSON:\n${result}`); 56 | } 57 | if (response.ok) return result as unknown as ResponseType; 58 | else throw new Error(`${response.status} ${response.statusText}\n${result.code}:${result.message}`); 59 | } 60 | } 61 | 62 | export class HyperbeamAPI { 63 | private apiKey = process.env.HB_API_KEY; 64 | private searchProvider: "duckduckgo" | "google" = "google"; 65 | private baseUrl = "https://engine.hyperbeam.com/v0"; 66 | 67 | private hasProtocol(s: string): boolean { 68 | try { 69 | const url = new URL(s); 70 | return url.protocol === "https:" || url.protocol === "http:"; 71 | } catch (e) { 72 | return false; 73 | } 74 | } 75 | 76 | private getSearchUrl(query: string): string { 77 | const searchUrls = { 78 | duckduckgo: "https://duckduckgo.com/?q=", 79 | google: "https://google.com/search?q=", 80 | }; 81 | return `${searchUrls[this.searchProvider]}${encodeURIComponent(query)}`; 82 | } 83 | 84 | setSearchProvider(searchProvider: HyperbeamAPI["searchProvider"]): void { 85 | this.searchProvider = searchProvider; 86 | } 87 | 88 | async createSession(sessionData: VMRequestBody = {}): Promise { 89 | const body: VMRequestBody = { 90 | ...sessionData, 91 | control_disable_default: false, 92 | offline_timeout: 300, 93 | hide_cursor: true, 94 | ublock: true, 95 | start_url: sessionData?.start_url 96 | ? this.hasProtocol(sessionData.start_url) 97 | ? sessionData.start_url 98 | : this.getSearchUrl(sessionData.start_url) 99 | : `https://${this.searchProvider}.com`, 100 | }; 101 | return HbFetch({ 102 | baseUrl: this.baseUrl, 103 | authorization: this.apiKey, 104 | path: "/vm", 105 | method: "POST", 106 | body, 107 | }).then((res) => new HyperbeamSession(this, res)); 108 | } 109 | 110 | async deleteSession(sessionId: string): Promise { 111 | return HbFetch({ 112 | baseUrl: this.baseUrl, 113 | authorization: this.apiKey, 114 | path: `/vm/${sessionId}`, 115 | method: "DELETE", 116 | }); 117 | } 118 | 119 | async getSession(sessionId: string): Promise { 120 | return HbFetch({ 121 | baseUrl: this.baseUrl, 122 | authorization: this.apiKey, 123 | path: `/vm/${sessionId}`, 124 | method: "GET", 125 | }).then((res) => new HyperbeamSession(this, res)); 126 | } 127 | } 128 | 129 | export class HyperbeamSession { 130 | sessionId: string; 131 | embedUrl: string; 132 | adminToken: string; 133 | api: HyperbeamAPI; 134 | terminationDate?: Date; 135 | isTerminated: boolean = false; 136 | 137 | constructor(api: HyperbeamAPI, props: VMResponse) { 138 | this.api = api; 139 | this.sessionId = props.session_id; 140 | this.embedUrl = props.embed_url; 141 | this.adminToken = props.admin_token; 142 | if (props.termination_date) { 143 | this.terminationDate = new Date(props.termination_date); 144 | this.isTerminated = this.terminationDate < new Date(); 145 | } 146 | } 147 | 148 | get baseUrl(): string { 149 | const parsedEmbedUrl = new URL(this.embedUrl); 150 | return parsedEmbedUrl.origin + parsedEmbedUrl.pathname; 151 | } 152 | 153 | async setPermissions( 154 | userId: string, 155 | permissions: Partial, 156 | ): Promise { 157 | return HbFetch]>({ 158 | baseUrl: this.baseUrl, 159 | authorization: this.adminToken, 160 | path: `/setPermissions`, 161 | method: "POST", 162 | body: [userId, permissions], 163 | }); 164 | } 165 | 166 | async kickUser(userId: string): Promise { 167 | return HbFetch({ 168 | baseUrl: this.baseUrl, 169 | authorization: this.adminToken, 170 | path: `/users/${userId}`, 171 | method: "DELETE", 172 | body: undefined, 173 | }); 174 | } 175 | 176 | async delete(): Promise { 177 | return this.api.deleteSession(this.sessionId); 178 | } 179 | } 180 | 181 | export default new HyperbeamAPI(); 182 | -------------------------------------------------------------------------------- /bot/classes/room.ts: -------------------------------------------------------------------------------- 1 | import { Session } from "@prisma/client"; 2 | import { Client, Room } from "colyseus"; 3 | import { customAlphabet } from "nanoid"; 4 | 5 | import Member from "../schemas/member"; 6 | import { RoomState } from "../schemas/room"; 7 | import { HyperbeamSession } from "./hyperbeam"; 8 | import { 9 | authenticateUser, 10 | connectHbUser, 11 | disposeSession, 12 | joinSession, 13 | leaveSession, 14 | setControl, 15 | setCursor, 16 | startSession, 17 | StartSessionOptions, 18 | } from "./sessions"; 19 | 20 | const nanoid = customAlphabet("6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz", 8); 21 | 22 | export type AuthenticatedClient = Omit & { 23 | auth: Awaited>; 24 | userData: Member; 25 | }; 26 | 27 | export type AuthOptions = { 28 | token?: string; 29 | deviceId?: string; 30 | }; 31 | 32 | export class BotRoom extends Room { 33 | session?: Session & { instance: HyperbeamSession }; 34 | guests: number[] = []; 35 | autoDispose = false; 36 | multiplayer = true; 37 | maxClients = 50; 38 | 39 | async onCreate(options: StartSessionOptions) { 40 | this.roomId = options.url || nanoid(); 41 | this.setState(new RoomState()); 42 | this.setPatchRate(40); 43 | this.setPrivate(true); 44 | this.state.ownerId = options.ownerId; 45 | this.state.password = options.password; 46 | this.state.isPasswordProtected = !!options.password; 47 | await this.registerMessageHandlers(); 48 | await startSession({ room: this, options }); 49 | } 50 | 51 | async onAuth(client: Client, options?: AuthOptions) { 52 | return authenticateUser({ 53 | room: this, 54 | client, 55 | token: options?.token, 56 | deviceId: options?.deviceId, 57 | }); 58 | } 59 | 60 | async onJoin(client: AuthenticatedClient) { 61 | await joinSession({ room: this, client }); 62 | } 63 | 64 | async onLeave(client: AuthenticatedClient) { 65 | await leaveSession({ room: this, client }); 66 | } 67 | 68 | async onDispose() { 69 | await disposeSession({ room: this }); 70 | } 71 | 72 | async registerMessageHandlers() { 73 | this.onMessage<{ type: "setCursor"; x: number; y: number }>( 74 | "setCursor", 75 | async (client: AuthenticatedClient, message) => { 76 | setCursor({ room: this, client, x: message.x, y: message.y }); 77 | }, 78 | ); 79 | this.onMessage<{ type: "setControl"; targetId: string; control: Member["control"] }>( 80 | "setControl", 81 | async (client: AuthenticatedClient, message) => { 82 | setControl({ room: this, client, targetId: message.targetId, control: message.control }); 83 | }, 84 | ); 85 | this.onMessage<{ type: "connectHbUser"; hbId: string }>( 86 | "connectHbUser", 87 | async (client: AuthenticatedClient, message) => { 88 | connectHbUser({ room: this, client, hbId: message.hbId }); 89 | }, 90 | ); 91 | this.onMessage<{ type: "authenticateMemberPassword"; password: string }>( 92 | "authenticateMemberPassword", 93 | async (client: AuthenticatedClient, message) => { 94 | if (message.password === this.state.password) { 95 | const target = this.state.members.get(client.userData.id); 96 | if (target) { 97 | await this.session?.instance?.setPermissions(target.hbId!, { control_disabled: false }); 98 | target.control = "enabled"; 99 | target.isPasswordAuthenticated = true; 100 | } 101 | } 102 | }, 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /bot/classes/sessions.ts: -------------------------------------------------------------------------------- 1 | import { Session, User } from "@prisma/client"; 2 | import { Client, matchMaker, ServerError } from "colyseus"; 3 | 4 | import Cursor from "../schemas/cursor"; 5 | import Member from "../schemas/member"; 6 | import color, { swatches } from "../utils/color"; 7 | import TokenHandler from "../utils/tokenHandler"; 8 | import db from "./database"; 9 | import Hyperbeam, { HyperbeamSession, VMRequestBody } from "./hyperbeam"; 10 | import { AuthenticatedClient, AuthOptions, BotRoom } from "./room"; 11 | 12 | export type StartSessionOptions = VMRequestBody & { 13 | url?: string; 14 | ownerId: string; 15 | existingSession?: BotRoom["session"] & { members?: User[] }; 16 | password?: string; 17 | }; 18 | 19 | type BaseContext = { room: BotRoom }; 20 | type AuthContext = BaseContext & { client: AuthenticatedClient }; 21 | 22 | export async function createSession(options: StartSessionOptions): Promise { 23 | let url: string = ""; 24 | try { 25 | const roomData = await matchMaker.createRoom("room", options); 26 | url = roomData.roomId; 27 | return db.session.findUniqueOrThrow({ where: { url } }); 28 | } catch (error) { 29 | if (url) await db.session.delete({ where: { url } }).catch(() => {}); 30 | throw new Error(`Failed to create session: ${error}`); 31 | } 32 | } 33 | 34 | export async function authenticateUser( 35 | ctx: BaseContext & { client: Client } & AuthOptions, 36 | ): Promise<{ token: string | undefined; guest: boolean; deviceId: string }> { 37 | let member: Member | undefined = undefined; 38 | if (!ctx.token) { 39 | const id = ctx.deviceId || ctx.client.sessionId; 40 | const existingGuestClient = ctx.room.clients.find((c) => c.auth?.deviceId === id); 41 | if (existingGuestClient) { 42 | member = existingGuestClient.userData; 43 | } else { 44 | member = new Member(); 45 | member.id = id; 46 | member.color = color(id) || swatches[Math.floor(Math.random() * swatches.length)]; 47 | member.name = "Guest "; 48 | let guestNumber = 1; 49 | while (ctx.room.guests.includes(guestNumber)) guestNumber++; 50 | ctx.room.guests.push(guestNumber); 51 | member.name += guestNumber; 52 | member.avatarUrl = `https://cdn.discordapp.com/embed/avatars/${guestNumber % 5}.png`; 53 | } 54 | } else if (ctx.token) { 55 | const result = TokenHandler.verify(ctx.token); 56 | if (!result) throw new ServerError(401, "Invalid token"); 57 | const { id, verify } = result; 58 | const user = await db.user.findFirst({ where: { id } }); 59 | if (!user) throw new ServerError(401, "Invalid token"); 60 | if (!verify(user)) throw new ServerError(401, "Invalid token"); 61 | member = new Member(); 62 | member.isAuthenticated = true; 63 | member.id = user.id; 64 | member.color = color(user.id) || swatches[Math.floor(Math.random() * swatches.length)]; 65 | member.name = `${user.username}${ 66 | user.discriminator ? (user.discriminator === "0" ? "" : `#${user.discriminator}`) : "" 67 | }`; 68 | member.avatarUrl = user.avatar 69 | ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` 70 | : `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`; 71 | } 72 | if (!member) throw new ServerError(401, "Could not authenticate user"); 73 | ctx.client.userData = member; 74 | ctx.client.send("identify", { id: member.id }); 75 | return { token: ctx.token, guest: !ctx.token, deviceId: ctx.deviceId || ctx.client.sessionId }; 76 | } 77 | 78 | export async function startSession(ctx: BaseContext & { options: StartSessionOptions }) { 79 | let hbSession: HyperbeamSession | undefined = undefined; 80 | const existingSession = ctx.options.existingSession; 81 | if (existingSession && existingSession.sessionId && existingSession.embedUrl) { 82 | ctx.room.session = existingSession; 83 | ctx.room.state.embedUrl = existingSession.embedUrl; 84 | ctx.room.state.sessionId = existingSession.sessionId; 85 | if (!existingSession.instance) { 86 | try { 87 | const instance = await Hyperbeam.getSession(existingSession.sessionId); 88 | if (instance.isTerminated) { 89 | await endSession(existingSession.sessionId, instance.terminationDate); 90 | throw new ServerError(500, "Session is terminated"); 91 | } 92 | ctx.room.session.instance = instance; 93 | } catch (error) { 94 | console.error(`Failed to get session instance: ${error}`); 95 | throw new ServerError(500, "Failed to get session instance"); 96 | } 97 | } 98 | // if (existingSession.members?.length) { 99 | // for (const member of existingSession.members) { 100 | // const m = new Member(); 101 | // m.id = member.id; 102 | // m.color = color(member.id) || swatches[Math.floor(Math.random() * swatches.length)]; 103 | // m.name = member.username + "#" + member.discriminator; 104 | // m.avatarUrl = member.avatar 105 | // ? `https://cdn.discordapp.com/avatars/${member.id}/${member.avatar}.png` 106 | // : `https://cdn.discordapp.com/embed/avatars/${+member.discriminator % 5}.png`; 107 | // ctx.room.state.members.set(m.id, m); 108 | // } 109 | // } 110 | // users should automatically reconnect, so we don't need to do anything else 111 | return; 112 | } else { 113 | try { 114 | hbSession = await Hyperbeam.createSession(ctx.options); 115 | } catch (e) { 116 | console.error(e); 117 | throw new ServerError(500, "Could not create session"); 118 | } 119 | if (hbSession && !hbSession.isTerminated) { 120 | const session = await db.session.create({ 121 | data: { 122 | embedUrl: hbSession.embedUrl, 123 | sessionId: hbSession.sessionId, 124 | adminToken: hbSession.adminToken, 125 | ownerId: ctx.options.ownerId, 126 | password: ctx.options.password, 127 | createdAt: new Date(), 128 | url: ctx.room.roomId, 129 | region: ctx.options.region, 130 | }, 131 | }); 132 | if (!ctx.room.state.ownerId) ctx.room.state.ownerId = ctx.options.ownerId; 133 | ctx.room.session = { ...session, instance: hbSession }; 134 | ctx.room.state.embedUrl = hbSession.embedUrl; 135 | ctx.room.state.sessionId = hbSession.sessionId; 136 | } else { 137 | throw new ServerError(500, "Could not create session"); 138 | } 139 | } 140 | } 141 | 142 | export async function joinSession(ctx: AuthContext) { 143 | if (!ctx.room.session) throw new ServerError(500, "Session does not exist"); 144 | if (!ctx.room.session.instance) { 145 | const instance = await Hyperbeam.getSession(ctx.room.session.sessionId); 146 | if (!instance) throw new ServerError(500, "Session does not exist"); 147 | ctx.room.session.instance = instance; 148 | } 149 | const member = ctx.client.userData; 150 | if (!member) return; 151 | member.control = ctx.room.state.isPasswordProtected ? "disabled" : member.control; 152 | ctx.room.state.members.set(member.id, member); 153 | 154 | if (ctx.client.auth.guest) return; 155 | const user = await db.user.findFirst({ where: { id: member.id } }); 156 | if (!user) return; 157 | await db.session.update({ 158 | where: { url: ctx.room.roomId }, 159 | data: { 160 | members: { 161 | connect: { id: user.id }, 162 | }, 163 | }, 164 | }); 165 | } 166 | 167 | export async function leaveSession(ctx: AuthContext) { 168 | const member = ctx.client.userData; 169 | if (!member) return; 170 | ctx.room.state.members.delete(member.id); 171 | if (ctx.client.auth.guest) { 172 | const guestNumber = +member.name.split(" ")[1]; 173 | ctx.room.guests.splice(ctx.room.guests.indexOf(guestNumber), 1); 174 | return; 175 | } 176 | const user = await db.user.findFirst({ where: { id: member.id } }); 177 | if (!user) return; 178 | await db.session.update({ 179 | where: { url: ctx.room.roomId }, 180 | data: { 181 | members: { 182 | connect: { id: user.id }, 183 | }, 184 | }, 185 | }); 186 | } 187 | 188 | export async function disposeSession(ctx: BaseContext) { 189 | if (!ctx.room.session) return; 190 | await Hyperbeam.deleteSession(ctx.room.session.sessionId); 191 | await endSession(ctx.room.session.sessionId); 192 | } 193 | 194 | export async function getActiveSessions(ownerId?: string): Promise<(Session & { members: User[] })[]> { 195 | return ownerId 196 | ? db.session.findMany({ where: { ownerId, endedAt: { equals: null } }, include: { members: true } }) 197 | : db.session.findMany({ where: { endedAt: { equals: null } }, include: { members: true } }); 198 | } 199 | 200 | export async function endAllSessions(ownerId?: string): Promise<(Session & { members: User[] })[]> { 201 | const sessions = await getActiveSessions(ownerId); 202 | for (let i = 0; i < sessions.length; i++) { 203 | try { 204 | const session = sessions[i]; 205 | const endedSession = await endSession(session.sessionId); 206 | if (endedSession) sessions[i] = endedSession; 207 | await Hyperbeam.deleteSession(session.sessionId).catch(() => {}); 208 | await matchMaker.remoteRoomCall(session.url, "disconnect").catch(() => {}); 209 | } catch { 210 | continue; 211 | } 212 | } 213 | return sessions; 214 | } 215 | 216 | export async function connectHbUser(ctx: AuthContext & { hbId: string }) { 217 | const member = ctx.client.userData; 218 | if (!member) return; 219 | member.hbId = ctx.hbId; 220 | await ctx.room.session?.instance?.setPermissions(ctx.hbId, { control_disabled: member.control === "disabled" }); 221 | } 222 | 223 | export async function setControl(ctx: AuthContext & { targetId: string; control: Member["control"] }) { 224 | const target = ctx.room.state.members.get(ctx.targetId); 225 | if (!target || target.control === ctx.control) return; 226 | // making conditions simpler to read 227 | const isSelf = target.id === ctx.client.userData.id; 228 | const isOwner = ctx.room.state.ownerId === ctx.client.userData.id; 229 | const isNotEnabling = ctx.control === "requesting" || ctx.control === "disabled"; 230 | const isAlreadyEnabled = target.control === "enabled"; 231 | // check conditions for setting control 232 | if (!target.hbId) { 233 | console.log(`Hyperbeam user ID not connected to target member ${target.id} (${target.name}).`); 234 | return; 235 | } 236 | if (!ctx.room.session?.instance) { 237 | console.log("Hyperbeam session not initialized."); 238 | if (ctx.room.session?.sessionId) { 239 | try { 240 | ctx.room.session.instance = await Hyperbeam.getSession(ctx.room.session.sessionId); 241 | } catch { 242 | console.log("Hyperbeam session not found."); 243 | } 244 | } 245 | return; 246 | } 247 | if (isAlreadyEnabled && ctx.control === "requesting") return; // already enabled, no need to request again 248 | if (isOwner) { 249 | await ctx.room.session.instance.setPermissions(target.hbId, { control_disabled: ctx.control !== "enabled" }); 250 | target.control = ctx.control === "disabled" ? "disabled" : "enabled"; 251 | } else if ((isSelf && isNotEnabling) || ctx.client.userData.id === ctx.room.state.ownerId) { 252 | await ctx.room.session.instance.setPermissions(target.hbId, { control_disabled: ctx.control !== "enabled" }); 253 | target.control = ctx.control; 254 | } 255 | } 256 | 257 | export async function setMultiplayer(ctx: AuthContext & { multiplayer: boolean }) { 258 | if (ctx.room.state.ownerId !== ctx.client.userData.id) return; 259 | const actions: Promise[] = []; 260 | if (!ctx.multiplayer) { 261 | for (const member of ctx.room.state.members.values()) { 262 | // dont disable control for the owner 263 | if (member.id === ctx.room.state.ownerId) continue; 264 | // preserve requesting control state 265 | if (member.control === "enabled") member.control = "disabled"; 266 | if (member.hbId && ctx.room.session?.instance) { 267 | // push to array without awaiting so that we can await all at once 268 | actions.push(ctx.room.session.instance.setPermissions(member.hbId, { control_disabled: true })); 269 | } 270 | } 271 | } else { 272 | for (const member of ctx.room.state.members.values()) { 273 | if (member.hbId && ctx.room.session?.instance) { 274 | // push to array without awaiting so that we can await all at once 275 | actions.push(ctx.room.session.instance.setPermissions(member.hbId, { control_disabled: false })); 276 | } 277 | } 278 | } 279 | await Promise.all(actions); 280 | // set multiplayer after all permissions have been updated 281 | ctx.room.multiplayer = ctx.multiplayer; 282 | } 283 | 284 | export async function setCursor(ctx: AuthContext & { x: number; y: number }) { 285 | ctx.room.state.members.set(ctx.client.userData.id, ctx.client.userData); 286 | if (!ctx.client.userData.cursor) ctx.client.userData.cursor = new Cursor(); 287 | if (ctx.client.userData.cursor.x === ctx.x && ctx.client.userData.cursor.y === ctx.y) return; 288 | if (ctx.x < 0 || ctx.y < 0 || ctx.x > 1 || ctx.y > 1) return; 289 | ctx.client.userData.cursor.x = ctx.x; 290 | ctx.client.userData.cursor.y = ctx.y; 291 | } 292 | 293 | export async function endSession(sessionId: string, endedAt = new Date()) { 294 | try { 295 | return db.session.update({ where: { sessionId }, data: { endedAt }, include: { members: true } }); 296 | } catch {} 297 | } 298 | 299 | export async function restartActiveSessions(): Promise { 300 | const sessions = await db.session.findMany({ 301 | where: { endedAt: { equals: null } }, 302 | include: { members: true }, 303 | }); 304 | const restartedSessions: Session[] = []; 305 | return Promise.allSettled( 306 | sessions 307 | .map(async (session) => { 308 | const room = matchMaker.getRoomById(session.url); 309 | if (!room) { 310 | try { 311 | await matchMaker 312 | .createRoom("room", { 313 | url: session.url, 314 | ownerId: session.ownerId, 315 | region: session.region, 316 | existingSession: session, 317 | password: session.password, 318 | } as StartSessionOptions) 319 | .then(() => restartedSessions.push(session)) 320 | .catch(() => {}); 321 | } catch (e) { 322 | console.error("Failed to create room", e); 323 | await endSession(session.sessionId); 324 | return; 325 | } 326 | } 327 | }) 328 | .filter((p) => !!p), 329 | ).then(() => restartedSessions); 330 | } 331 | -------------------------------------------------------------------------------- /bot/commands/start.ts: -------------------------------------------------------------------------------- 1 | // import { time } from "discord.js"; 2 | import { CommandContext, CommandOptionType, SlashCommand, SlashCreator } from "slash-create"; 3 | 4 | import db from "../classes/database"; 5 | import { createSession, endAllSessions, StartSessionOptions } from "../classes/sessions"; 6 | import { BotClient } from "../types"; 7 | 8 | const regions = { 9 | NA: "North America", 10 | EU: "Europe", 11 | AS: "Asia", 12 | }; 13 | 14 | export default class Start extends SlashCommand { 15 | constructor(creator: SlashCreator) { 16 | super(creator, { 17 | name: "start", 18 | description: "Start a multiplayer browser session", 19 | options: [ 20 | { 21 | type: CommandOptionType.STRING, 22 | name: "website", 23 | description: "The website to open in the session", 24 | }, 25 | { 26 | type: CommandOptionType.STRING, 27 | name: "region", 28 | description: "The region to use for the session", 29 | choices: Object.entries(regions).map(([value, name]) => ({ name, value })), 30 | }, 31 | { 32 | type: CommandOptionType.STRING, 33 | name: "extra", 34 | description: "Extra options 👀", 35 | }, 36 | { 37 | type: CommandOptionType.STRING, 38 | name: "password", 39 | description: "Password to protect the session", 40 | }, 41 | ], 42 | }); 43 | } 44 | 45 | async run(ctx: CommandContext) { 46 | try { 47 | await endAllSessions(ctx.user.id).catch(() => {}); 48 | const options = { 49 | region: ctx.options.region || "NA", 50 | ownerId: ctx.user.id, 51 | start_url: ctx.options.website, 52 | password: ctx.options.password, 53 | } as StartSessionOptions; 54 | 55 | const extraOptions = ctx.options.extra?.split(" ") ?? []; 56 | const features: string[] = []; 57 | if (extraOptions.includes("--1080p")) { 58 | options.width = 1920; 59 | options.height = 1080; 60 | features.push("1080p"); 61 | } 62 | if (extraOptions.includes("--60fps")) { 63 | options.fps = 60; 64 | features.push("60fps"); 65 | } 66 | if (extraOptions.includes("--webgl")) { 67 | options.webgl = true; 68 | features.push("WebGL"); 69 | } 70 | if (extraOptions.includes("--kiosk")) { 71 | options.kiosk = true; 72 | features.push("Kiosk"); 73 | } 74 | if (extraOptions.includes("--nsfw")) { 75 | options.nsfw = true; 76 | features.push("NSFW"); 77 | } 78 | const session = await createSession(options); 79 | await ctx.send({ 80 | embeds: [ 81 | { 82 | title: "Started a multiplayer browser!", 83 | // description: `Started ${time(session.createdAt, "R")} by ${ctx.user.mention}`, 84 | description: [ 85 | "Share this link to browse together -", 86 | `${process.env.VITE_CLIENT_BASE_URL}/${session.url}`, 87 | `(${[regions[session.region || "NA"], ...features].join(", ")})`, 88 | "", 89 | `[GitHub](${process.env.VITE_GITHUB_URL}) • [Support](${process.env.VITE_DISCORD_SUPPORT_SERVER})`, 90 | ].join("\n"), 91 | }, 92 | ], 93 | components: [ 94 | { 95 | type: 1, 96 | components: [ 97 | { 98 | type: 2, 99 | label: "Start browsing", 100 | style: 5, 101 | url: `${process.env.VITE_CLIENT_BASE_URL}/${session.url}`, 102 | }, 103 | ], 104 | }, 105 | ], 106 | }); 107 | 108 | const startMessage = await ctx.fetch(); 109 | await db.session.update({ 110 | where: { 111 | url: session.url, 112 | }, 113 | data: { 114 | messageId: startMessage.id, 115 | channelId: ctx.channelID, 116 | guildId: ctx.guildID, 117 | }, 118 | }); 119 | 120 | return startMessage; 121 | } catch (e) { 122 | console.error(e); 123 | return ctx.send({ 124 | embeds: [ 125 | { 126 | title: "We ran into an error", 127 | description: "We couldn't start your multiplayer browser. Please try again later.", 128 | }, 129 | ], 130 | components: [ 131 | { 132 | type: 1, 133 | components: [ 134 | { 135 | type: 2, 136 | label: "Get support", 137 | style: 5, 138 | url: process.env.VITE_DISCORD_SUPPORT_SERVER, 139 | }, 140 | ], 141 | }, 142 | ], 143 | }); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /bot/commands/stats.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import duration from "pretty-ms"; 3 | import { CommandContext, SlashCommand, SlashCreator } from "slash-create"; 4 | 5 | import db from "../classes/database"; 6 | import { BotClient } from "../types"; 7 | 8 | export default class Stats extends SlashCommand { 9 | constructor(creator: SlashCreator) { 10 | super(creator, { 11 | name: "stats", 12 | description: "Display details about the bot", 13 | }); 14 | } 15 | 16 | async run(ctx: CommandContext) { 17 | const sessionCount = await db.session.count(); 18 | // const activeSessionCount = await db.session.count({ where: { endedAt: null } }); 19 | const dispatchTime = new Date(); 20 | const regions = ["NA", "EU", "AS"]; 21 | const dispatchStats = await fetch("https://engine.hyperbeam.com/ok").then((res) => 22 | res.ok ? `${new Date().getTime() - dispatchTime.getTime()}ms` : "Offline", 23 | ); 24 | const regionStats = await Promise.all( 25 | regions.map(async (region) => { 26 | const regionTime = new Date(); 27 | return fetch(`https://engine.hyperbeam.com/vm/ok?reg=${region}`).then((res) => ({ 28 | region, 29 | status: res.ok ? `${new Date().getTime() - regionTime.getTime()}ms` : "Offline", 30 | })); 31 | }), 32 | ); 33 | await ctx.send({ 34 | embeds: [ 35 | { 36 | title: "Hyperbeam Bot", 37 | fields: [ 38 | { 39 | name: "Status", 40 | value: `${this.client.guilds.cache.size.toString()} servers joined, ${sessionCount.toString()} sessions created`, 41 | }, 42 | { 43 | name: "API Status", 44 | value: `Online (**Discord**: ${this.client.ws.ping}ms, **Hyperbeam**: ${dispatchStats})`, 45 | }, 46 | { 47 | name: "Regions", 48 | value: regionStats.map((r) => `**${r.region}**: ${r.status}`).join(", "), 49 | }, 50 | ], 51 | footer: { 52 | text: `Running for ${duration(this.client.uptime!, { compact: true })} using ${Math.round( 53 | process.memoryUsage().heapUsed / 1024 / 1024, 54 | )}MB memory`, 55 | }, 56 | }, 57 | ], 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /bot/commands/stop.ts: -------------------------------------------------------------------------------- 1 | import { APIEmbedField, Message, time } from "discord.js"; 2 | import { CommandContext, SlashCommand, SlashCreator } from "slash-create"; 3 | 4 | import database from "../classes/database"; 5 | import { endAllSessions } from "../classes/sessions"; 6 | import { BotClient } from "../types"; 7 | import inviteUrl from "../utils/inviteUrl"; 8 | 9 | export default class Stop extends SlashCommand { 10 | constructor(creator: SlashCreator) { 11 | super(creator, { 12 | name: "stop", 13 | description: "Stop a multiplayer browser session", 14 | }); 15 | } 16 | 17 | async run(ctx: CommandContext) { 18 | await ctx.defer(true); 19 | let sessions: Awaited> = []; 20 | try { 21 | sessions = await endAllSessions(ctx.user.id); 22 | } catch (err) { 23 | console.error(err); 24 | } 25 | 26 | let startCommandId: string = ""; 27 | const startCommand = ctx.creator.commands.find((command) => command.commandName === "start"); 28 | if (startCommand) startCommandId = startCommand.ids.get("global") ?? ""; 29 | 30 | const feedbackButtons = [ 31 | { 32 | type: 2, 33 | emoji: { 34 | name: "😀", 35 | }, 36 | style: 2, 37 | custom_id: "feedback-good", 38 | }, 39 | { 40 | type: 2, 41 | emoji: { 42 | name: "🫤", 43 | }, 44 | style: 2, 45 | custom_id: "feedback-neutral", 46 | }, 47 | { 48 | type: 2, 49 | emoji: { 50 | name: "🙁", 51 | }, 52 | style: 2, 53 | custom_id: "feedback-bad", 54 | }, 55 | ]; 56 | 57 | const startHintFields: APIEmbedField[] = []; 58 | if (startCommandId) 59 | startHintFields.push({ 60 | name: "Want to browse the web together?", 61 | value: `Use and share the link. It's that easy!`, 62 | }); 63 | 64 | const supportButtons = [ 65 | { 66 | type: 2, 67 | label: "Add to Server", 68 | style: 5, 69 | url: inviteUrl, 70 | }, 71 | { 72 | type: 2, 73 | label: "Get support", 74 | style: 5, 75 | url: process.env.VITE_DISCORD_SUPPORT_SERVER, 76 | }, 77 | ]; 78 | 79 | if (sessions.length) { 80 | for (const session of sessions) { 81 | let existingMessage: Message | undefined = undefined; 82 | if (session.channelId && session.messageId) { 83 | if (session.guildId) { 84 | const guild = this.client.guilds.cache.get(session.guildId); 85 | if (guild) { 86 | const channel = guild.channels.cache.get(session.channelId); 87 | if (channel?.isTextBased()) existingMessage = await channel.messages.fetch(session.messageId); 88 | } 89 | } else { 90 | const channel = this.client.channels.cache.get(session.channelId); 91 | if (channel?.isTextBased()) existingMessage = await channel.messages.fetch(session.messageId); 92 | } 93 | } 94 | if (existingMessage) { 95 | const description: string[] = []; 96 | if (session.endedAt) { 97 | let sessionInfo = `This multiplayer browser was stopped at ${time(session.endedAt)}`; 98 | if (session.members && session.members.length) { 99 | sessionInfo += ` with ${session.members.length} participants`; 100 | } 101 | sessionInfo += "."; 102 | description.push(sessionInfo); 103 | } 104 | 105 | await existingMessage.edit({ 106 | embeds: [ 107 | { 108 | title: "Thanks for using the bot!", 109 | description: description.length ? description.join("\n") : undefined, 110 | fields: startHintFields, 111 | }, 112 | ], 113 | components: [ 114 | { 115 | type: 1, 116 | components: supportButtons, 117 | }, 118 | ], 119 | }); 120 | } 121 | } 122 | } 123 | 124 | const requestFeedbackField = { 125 | name: "Let us know how it went!", 126 | value: "Your feedback helps us improve the bot.", 127 | }; 128 | 129 | await ctx.send({ 130 | embeds: [ 131 | { 132 | title: sessions.length 133 | ? `Stopped ${sessions.length > 1 ? `${sessions.length} multiplayer browsers` : "multiplayer browser"}` 134 | : "No multiplayer browser was active", 135 | fields: !sessions.length ? startHintFields : [requestFeedbackField], 136 | }, 137 | ], 138 | components: [{ type: 1, components: sessions.length ? feedbackButtons : supportButtons }], 139 | ephemeral: true, 140 | }); 141 | 142 | if (sessions.length) { 143 | for (const button of feedbackButtons) { 144 | ctx.registerComponent(button.custom_id, async (interaction) => { 145 | await interaction.editParent({ 146 | embeds: [ 147 | { 148 | title: "Thanks for your feedback!", 149 | description: "Join the support server to suggest new features, talk to the developers and more.", 150 | }, 151 | ], 152 | components: [ 153 | { 154 | type: 1, 155 | components: supportButtons, 156 | }, 157 | ], 158 | }); 159 | sessions.forEach(async (session) => { 160 | await database.session.update({ 161 | where: { url: session.url }, 162 | data: { feedback: button.custom_id.replace("feedback-", "") }, 163 | }); 164 | }); 165 | }); 166 | } 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /bot/index.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, Client, GatewayDispatchEvents, GatewayIntentBits } from "discord.js"; 2 | import dotenv from "dotenv"; 3 | import path from "path"; 4 | import { GatewayServer, SlashCreator } from "slash-create"; 5 | 6 | import server from "./classes/api"; 7 | import database from "./classes/database"; 8 | import { updateUser } from "./classes/discord"; 9 | import { restartActiveSessions } from "./classes/sessions"; 10 | import { BotClient } from "./types"; 11 | 12 | dotenv.config({ path: path.join(__dirname, "../../.env") }); 13 | 14 | const port = parseInt(process.env.VITE_API_SERVER_PORT || "3000", 10); 15 | server.listen(port).then(async () => { 16 | console.log(`API server listening on port ${port}`); 17 | await restartActiveSessions().then((sessions) => { 18 | if (sessions.length) console.log(`Restarted ${sessions.length} sessions`); 19 | }); 20 | }); 21 | 22 | const client = new Client({ 23 | intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], 24 | shards: "auto", 25 | }) as BotClient; 26 | client.db = database; 27 | 28 | const setActivity = (user: typeof client.user) => 29 | user?.setActivity({ 30 | name: "/start to start browsing!", 31 | type: ActivityType.Playing, 32 | }); 33 | 34 | client.on("ready", () => { 35 | console.log("Ready!"); 36 | if (client.user) setActivity(client.user); 37 | setInterval(() => { 38 | if (client.user) setActivity(client.user); 39 | }, 3600 * 1000); 40 | }); 41 | 42 | const creator = new SlashCreator({ 43 | applicationID: process.env.DISCORD_CLIENT_ID!, 44 | token: process.env.DISCORD_BOT_TOKEN!, 45 | client, 46 | }); 47 | 48 | creator.on("warn", (message) => console.warn(message)); 49 | creator.on("error", (error) => console.error(error)); 50 | creator.on("synced", () => console.info("Commands synced!")); 51 | creator.on("commandRun", (command, _, ctx) => { 52 | updateUser(ctx.user); 53 | const { id, username, discriminator } = ctx.user; 54 | console.info( 55 | `${username}${discriminator ? (discriminator === "0" ? "" : `#${discriminator}`) : ""} (${id}) ran command ${ 56 | command.commandName 57 | }`, 58 | ); 59 | }); 60 | creator.on("commandRegister", (command) => console.info(`Registered command ${command.commandName}`)); 61 | creator.on("commandError", (command, error) => console.error(`Command ${command.commandName}:`, error)); 62 | 63 | creator 64 | .withServer(new GatewayServer((handler) => client.ws.on(GatewayDispatchEvents.InteractionCreate, handler))) 65 | .registerCommandsIn(path.join(__dirname, "commands")) 66 | .syncCommands({ syncPermissions: false }); 67 | 68 | client.login(process.env.DISCORD_BOT_TOKEN); 69 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20220731062710_initial_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Room" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "url" TEXT NOT NULL, 5 | "name" TEXT NOT NULL, 6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "accessedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "ownerId" TEXT NOT NULL, 9 | "requiresAuth" BOOLEAN NOT NULL DEFAULT false, 10 | "memberCount" INTEGER NOT NULL DEFAULT 0, 11 | CONSTRAINT "Room_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "User" ( 16 | "id" TEXT NOT NULL PRIMARY KEY, 17 | "hash" TEXT, 18 | "email" TEXT, 19 | "avatar" TEXT, 20 | "username" TEXT NOT NULL, 21 | "discriminator" TEXT NOT NULL, 22 | "accessToken" TEXT, 23 | "refreshToken" TEXT 24 | ); 25 | 26 | -- CreateTable 27 | CREATE TABLE "Session" ( 28 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 29 | "roomId" TEXT NOT NULL, 30 | "sessionId" TEXT NOT NULL, 31 | "embedUrl" TEXT NOT NULL, 32 | "adminToken" TEXT NOT NULL, 33 | "terminationDate" TEXT, 34 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 35 | "duration" INTEGER NOT NULL DEFAULT 0, 36 | CONSTRAINT "Session_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 37 | ); 38 | 39 | -- CreateTable 40 | CREATE TABLE "RoomMember" ( 41 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 42 | "roomId" TEXT NOT NULL, 43 | "userId" TEXT NOT NULL, 44 | CONSTRAINT "RoomMember_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 45 | CONSTRAINT "RoomMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 46 | ); 47 | 48 | -- CreateIndex 49 | CREATE UNIQUE INDEX "Room_url_key" ON "Room"("url"); 50 | 51 | -- CreateIndex 52 | CREATE UNIQUE INDEX "Room_ownerId_key" ON "Room"("ownerId"); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "User_hash_key" ON "User"("hash"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 62 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20220913184207_change_model/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Room` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `RoomMember` table. If the table is not empty, all the data it contains will be lost. 6 | - The primary key for the `Session` table will be changed. If it partially fails, the table could be left without primary key constraint. 7 | - You are about to drop the column `duration` on the `Session` table. All the data in the column will be lost. 8 | - You are about to drop the column `id` on the `Session` table. All the data in the column will be lost. 9 | - You are about to drop the column `roomId` on the `Session` table. All the data in the column will be lost. 10 | - You are about to drop the column `terminationDate` on the `Session` table. All the data in the column will be lost. 11 | - Added the required column `ownerId` to the `Session` table without a default value. This is not possible if the table is not empty. 12 | - Added the required column `url` to the `Session` table without a default value. This is not possible if the table is not empty. 13 | 14 | */ 15 | -- DropIndex 16 | DROP INDEX "Room_ownerId_key"; 17 | 18 | -- DropIndex 19 | DROP INDEX "Room_url_key"; 20 | 21 | -- DropTable 22 | PRAGMA foreign_keys=off; 23 | DROP TABLE "Room"; 24 | PRAGMA foreign_keys=on; 25 | 26 | -- DropTable 27 | PRAGMA foreign_keys=off; 28 | DROP TABLE "RoomMember"; 29 | PRAGMA foreign_keys=on; 30 | 31 | -- CreateTable 32 | CREATE TABLE "_members" ( 33 | "A" TEXT NOT NULL, 34 | "B" TEXT NOT NULL, 35 | CONSTRAINT "_members_A_fkey" FOREIGN KEY ("A") REFERENCES "Session" ("sessionId") ON DELETE CASCADE ON UPDATE CASCADE, 36 | CONSTRAINT "_members_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 37 | ); 38 | 39 | -- CreateTable 40 | CREATE TABLE "_banned" ( 41 | "A" TEXT NOT NULL, 42 | "B" TEXT NOT NULL, 43 | CONSTRAINT "_banned_A_fkey" FOREIGN KEY ("A") REFERENCES "Session" ("sessionId") ON DELETE CASCADE ON UPDATE CASCADE, 44 | CONSTRAINT "_banned_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 45 | ); 46 | 47 | -- RedefineTables 48 | PRAGMA foreign_keys=OFF; 49 | CREATE TABLE "new_Session" ( 50 | "url" TEXT NOT NULL, 51 | "sessionId" TEXT NOT NULL PRIMARY KEY, 52 | "embedUrl" TEXT NOT NULL, 53 | "adminToken" TEXT NOT NULL, 54 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 55 | "endedAt" DATETIME, 56 | "ownerId" TEXT NOT NULL, 57 | CONSTRAINT "Session_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 58 | ); 59 | INSERT INTO "new_Session" ("adminToken", "createdAt", "embedUrl", "sessionId") SELECT "adminToken", "createdAt", "embedUrl", "sessionId" FROM "Session"; 60 | DROP TABLE "Session"; 61 | ALTER TABLE "new_Session" RENAME TO "Session"; 62 | CREATE UNIQUE INDEX "Session_url_key" ON "Session"("url"); 63 | CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId"); 64 | PRAGMA foreign_key_check; 65 | PRAGMA foreign_keys=ON; 66 | 67 | -- CreateIndex 68 | CREATE UNIQUE INDEX "_members_AB_unique" ON "_members"("A", "B"); 69 | 70 | -- CreateIndex 71 | CREATE INDEX "_members_B_index" ON "_members"("B"); 72 | 73 | -- CreateIndex 74 | CREATE UNIQUE INDEX "_banned_AB_unique" ON "_banned"("A", "B"); 75 | 76 | -- CreateIndex 77 | CREATE INDEX "_banned_B_index" ON "_banned"("B"); 78 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20220917074506_track_item_updates/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Session" ( 4 | "url" TEXT NOT NULL, 5 | "sessionId" TEXT NOT NULL PRIMARY KEY, 6 | "embedUrl" TEXT NOT NULL, 7 | "adminToken" TEXT NOT NULL, 8 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "endedAt" DATETIME, 11 | "ownerId" TEXT NOT NULL, 12 | CONSTRAINT "Session_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 13 | ); 14 | INSERT INTO "new_Session" ("adminToken", "createdAt", "embedUrl", "endedAt", "ownerId", "sessionId", "url") SELECT "adminToken", "createdAt", "embedUrl", "endedAt", "ownerId", "sessionId", "url" FROM "Session"; 15 | DROP TABLE "Session"; 16 | ALTER TABLE "new_Session" RENAME TO "Session"; 17 | CREATE UNIQUE INDEX "Session_url_key" ON "Session"("url"); 18 | CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId"); 19 | CREATE TABLE "new_User" ( 20 | "id" TEXT NOT NULL PRIMARY KEY, 21 | "hash" TEXT, 22 | "email" TEXT, 23 | "avatar" TEXT, 24 | "username" TEXT NOT NULL, 25 | "discriminator" TEXT NOT NULL, 26 | "accessToken" TEXT, 27 | "refreshToken" TEXT, 28 | "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 29 | ); 30 | INSERT INTO "new_User" ("accessToken", "avatar", "discriminator", "email", "hash", "id", "refreshToken", "username") SELECT "accessToken", "avatar", "discriminator", "email", "hash", "id", "refreshToken", "username" FROM "User"; 31 | DROP TABLE "User"; 32 | ALTER TABLE "new_User" RENAME TO "User"; 33 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 34 | CREATE UNIQUE INDEX "User_hash_key" ON "User"("hash"); 35 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 36 | PRAGMA foreign_key_check; 37 | PRAGMA foreign_keys=ON; 38 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20221004205946_store_region/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Session" ( 4 | "url" TEXT NOT NULL, 5 | "sessionId" TEXT NOT NULL PRIMARY KEY, 6 | "embedUrl" TEXT NOT NULL, 7 | "adminToken" TEXT NOT NULL, 8 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "region" TEXT NOT NULL DEFAULT 'NA', 10 | "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | "endedAt" DATETIME, 12 | "ownerId" TEXT NOT NULL, 13 | CONSTRAINT "Session_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 14 | ); 15 | INSERT INTO "new_Session" ("adminToken", "createdAt", "embedUrl", "endedAt", "ownerId", "sessionId", "updatedAt", "url") SELECT "adminToken", "createdAt", "embedUrl", "endedAt", "ownerId", "sessionId", "updatedAt", "url" FROM "Session"; 16 | DROP TABLE "Session"; 17 | ALTER TABLE "new_Session" RENAME TO "Session"; 18 | CREATE UNIQUE INDEX "Session_url_key" ON "Session"("url"); 19 | CREATE UNIQUE INDEX "Session_sessionId_key" ON "Session"("sessionId"); 20 | PRAGMA foreign_key_check; 21 | PRAGMA foreign_keys=ON; 22 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20221105131227_add_discord_ids/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Session" ADD COLUMN "channelId" TEXT; 3 | ALTER TABLE "Session" ADD COLUMN "guildId" TEXT; 4 | ALTER TABLE "Session" ADD COLUMN "messageId" TEXT; 5 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20221107220256_get_session_feedback/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Session" ADD COLUMN "feedback" TEXT; 3 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20230222183204_/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Session" ADD COLUMN "password" TEXT; 3 | -------------------------------------------------------------------------------- /bot/prisma/migrations/20230510193702_updated_discriminator_to_be_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_User" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "hash" TEXT, 6 | "email" TEXT, 7 | "avatar" TEXT, 8 | "username" TEXT NOT NULL, 9 | "discriminator" TEXT, 10 | "accessToken" TEXT, 11 | "refreshToken" TEXT, 12 | "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 13 | ); 14 | INSERT INTO "new_User" ("accessToken", "avatar", "discriminator", "email", "hash", "id", "refreshToken", "updatedAt", "username") SELECT "accessToken", "avatar", "discriminator", "email", "hash", "id", "refreshToken", "updatedAt", "username" FROM "User"; 15 | DROP TABLE "User"; 16 | ALTER TABLE "new_User" RENAME TO "User"; 17 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 18 | CREATE UNIQUE INDEX "User_hash_key" ON "User"("hash"); 19 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 20 | PRAGMA foreign_key_check; 21 | PRAGMA foreign_keys=ON; 22 | -------------------------------------------------------------------------------- /bot/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 = "sqlite" -------------------------------------------------------------------------------- /bot/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @unique 12 | hash String? @unique // keep serverside only 13 | email String? @unique 14 | avatar String? 15 | username String 16 | discriminator String? 17 | accessToken String? // keep serverside only 18 | refreshToken String? // keep serverside only 19 | ownedSessions Session[] @relation("owner") 20 | memberOf Session[] @relation("members") 21 | bannedFrom Session[] @relation("banned") 22 | updatedAt DateTime @default(now()) @updatedAt 23 | } 24 | 25 | model Session { 26 | url String @unique // session url 27 | sessionId String @id @unique // hyperbeam session id 28 | embedUrl String // iframe embed url 29 | adminToken String // admin token for this session 30 | createdAt DateTime @default(now()) 31 | region String @default("NA") // region of the session 32 | updatedAt DateTime @default(now()) @updatedAt 33 | endedAt DateTime? // when the session ended 34 | owner User @relation("owner", fields: [ownerId], references: [id]) 35 | ownerId String 36 | members User[] @relation("members") 37 | bans User[] @relation("banned") 38 | channelId String? // discord channel id 39 | guildId String? // discord guild id 40 | messageId String? // discord message id 41 | feedback String? // feedback from the user 42 | password String? // password for the session 43 | } 44 | -------------------------------------------------------------------------------- /bot/schemas/cursor.ts: -------------------------------------------------------------------------------- 1 | import { Schema, type } from "@colyseus/schema"; 2 | 3 | export class Cursor extends Schema { 4 | @type("number") x: number = 0; 5 | @type("number") y: number = 0; 6 | @type("string") message?: string; 7 | } 8 | 9 | export default Cursor; 10 | -------------------------------------------------------------------------------- /bot/schemas/member.ts: -------------------------------------------------------------------------------- 1 | import { Schema, type } from "@colyseus/schema"; 2 | 3 | import { Cursor } from "./cursor"; 4 | 5 | export class Member extends Schema { 6 | @type("string") id: string; 7 | @type("string") hbId?: string; 8 | @type("string") name: string; 9 | @type("string") avatarUrl: string; 10 | @type("string") color: string = "#000000"; 11 | @type("string") control: "disabled" | "requesting" | "enabled" = "enabled"; 12 | @type("boolean") isAuthenticated: boolean = false; 13 | @type("boolean") isPasswordAuthenticated: boolean = false; 14 | @type(Cursor) cursor: Cursor; 15 | } 16 | 17 | export default Member; 18 | -------------------------------------------------------------------------------- /bot/schemas/room.ts: -------------------------------------------------------------------------------- 1 | import { MapSchema, Schema, type } from "@colyseus/schema"; 2 | 3 | import { Member } from "./member"; 4 | 5 | export class RoomState extends Schema { 6 | @type({ map: Member }) members = new MapSchema(); 7 | @type("string") embedUrl?: string; 8 | @type("string") sessionId?: string; 9 | @type("string") ownerId: string; 10 | @type("boolean") isPasswordProtected: boolean = false; 11 | // Not synced 12 | password?: string; 13 | } 14 | 15 | export default RoomState; 16 | -------------------------------------------------------------------------------- /bot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDirs": [".", "../shared/"], 4 | "composite": true, 5 | "target": "es2020", 6 | "module": "commonjs", 7 | "outDir": "../dist/bot", 8 | "strict": false, 9 | "strictNullChecks": true, 10 | "esModuleInterop": true, 11 | "checkJs": false, 12 | "allowSyntheticDefaultImports": true, 13 | "experimentalDecorators": true, 14 | "resolveJsonModule": true, 15 | "moduleResolution": "node" 16 | }, 17 | "include": ["./**/*.ts", "./**/*.d.ts", "../env.d.ts", "../shared/*.ts", "../shared/*.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /bot/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "discord.js"; 2 | 3 | import Database from "./classes/database"; 4 | 5 | // slash-create 6 | export interface BotClient extends Client { 7 | db: typeof Database; 8 | } 9 | -------------------------------------------------------------------------------- /bot/utils/color.ts: -------------------------------------------------------------------------------- 1 | export const swatches = [ 2 | "#343a40", 3 | "#e03131", 4 | "#c2255c", 5 | "#9c36b5", 6 | "#6741d9", 7 | "#3b5bdb", 8 | "#1971c2", 9 | "#0c8599", 10 | "#099268", 11 | "#2f9e44", 12 | "#f08c00", 13 | "#e8590c", 14 | ]; 15 | 16 | export default function color(id: string) { 17 | const seed = id.length ? id : Math.round(Math.random() * 100).toString(); 18 | const index = seed.charCodeAt(seed.length - 1) % swatches.length; 19 | return swatches[index]; 20 | } 21 | -------------------------------------------------------------------------------- /bot/utils/inviteUrl.ts: -------------------------------------------------------------------------------- 1 | const inviteUrl = new URL("https://discord.com/api/oauth2/authorize"); 2 | inviteUrl.search = new URLSearchParams({ 3 | client_id: process.env.VITE_CLIENT_ID, 4 | redirect_uri: process.env.VITE_CLIENT_BASE_URL + "/authorize", 5 | response_type: "code", 6 | scope: "identify email bot applications.commands", 7 | permissions: "277062470720", 8 | }).toString(); 9 | 10 | export default inviteUrl.toString(); 11 | -------------------------------------------------------------------------------- /bot/utils/sanitize.ts: -------------------------------------------------------------------------------- 1 | import { Session, User } from "@prisma/client"; 2 | 3 | export function pick(obj: T, ...keys: K[]): Pick { 4 | const ret: any = {}; 5 | keys.forEach((key: K) => { 6 | ret[key] = obj[key]; 7 | }); 8 | return ret as Pick; 9 | } 10 | 11 | export const user = (data: User) => pick(data, "id", "username", "avatar", "discriminator"); 12 | export const session = (data: Session) => pick(data, "sessionId", "url", "createdAt", "endedAt", "ownerId"); 13 | 14 | export default { user, session }; 15 | -------------------------------------------------------------------------------- /bot/utils/tokenHandler.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import jwt from "jsonwebtoken"; 3 | 4 | export class TokenHandler { 5 | static generate(id: string, hash: string): string { 6 | return jwt.sign({ id }, hash); 7 | } 8 | 9 | static verify(token: string): false | { id: string; verify: (user: User) => boolean } { 10 | const decoded = jwt.decode(token) as { id: string }; 11 | if ( 12 | typeof decoded !== "object" || 13 | !decoded || 14 | !Object.keys(decoded).includes("id") || 15 | typeof decoded.id !== "string" 16 | ) 17 | return false; 18 | return { 19 | id: decoded?.id, 20 | verify: (user: User) => user.id === decoded.id && !!user.hash && !!jwt.verify(token, user.hash), 21 | }; 22 | } 23 | } 24 | 25 | export default TokenHandler; 26 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 30 | Hyperbeam Bot 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /client/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hyperbeam Bot", 3 | "short_name": "Hyperbeam Bot", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#1d1e24", 17 | "background_color": "#1d1e24", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /client/src/App.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /client/src/assets/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperbeam/discord-bot/94e01c78b307e534e4ccd45851f8353833726fed/client/src/assets/demo.mp4 -------------------------------------------------------------------------------- /client/src/assets/scss/_reset.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Made by Elly Loel - https://ellyloel.com/ 3 | With inspiration from: 4 | - Josh W Comeau - https://courses.joshwcomeau.com/css-for-js/treasure-trove/010-global-styles/ 5 | - Andy Bell - https://piccalil.li/blog/a-modern-css-reset/ 6 | - Adam Argyle - https://unpkg.com/open-props@1.3.16/normalize.min.css / https://codepen.io/argyleink/pen/KKvRORE 7 | 8 | Notes: 9 | - `:where()` is used to lower specificity for easy overriding. 10 | */ 11 | 12 | * { 13 | /* Remove default margin on everything */ 14 | margin: 0; 15 | /* Remove default padding on everything */ 16 | padding: 0; 17 | /* Calc `em` based line height, bigger line height for smaller font size and smaller line height for bigger font size: https://kittygiraudel.com/2020/05/18/using-calc-to-figure-out-optimal-line-height/ */ 18 | line-height: calc(0.25rem + 1em + 0.25rem); 19 | } 20 | 21 | /* Use a more-intuitive box-sizing model on everything */ 22 | *, 23 | ::before, 24 | ::after { 25 | box-sizing: border-box; 26 | } 27 | 28 | /* Remove border and set sensible defaults for backgrounds, on all elements except fieldset progress and meter */ 29 | *:where(:not(fieldset, progress, meter)) { 30 | border-width: 0; 31 | border-style: solid; 32 | background-origin: border-box; 33 | background-repeat: no-repeat; 34 | } 35 | 36 | html { 37 | /* Allow percentage-based heights in the application */ 38 | block-size: 100%; 39 | /* Making sure text size is only controlled by font-size */ 40 | -webkit-text-size-adjust: none; 41 | } 42 | 43 | /* Smooth scrolling for users that don't prefer reduced motion */ 44 | @media (prefers-reduced-motion: no-preference) { 45 | html:focus-within { 46 | scroll-behavior: smooth; 47 | } 48 | } 49 | 50 | body { 51 | /* Improve text rendering */ 52 | -webkit-font-smoothing: antialiased; 53 | /* https://marco.org/2012/11/15/text-rendering-optimize-legibility */ 54 | text-rendering: optimizeSpeed; 55 | /* Allow percentage-based heights in the application */ 56 | min-block-size: 100%; 57 | /* https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter#example_2 */ 58 | /* scrollbar-gutter: stable both-edges; Removed until this bug is fixed: https://bugs.chromium.org/p/chromium/issues/detail?id=1318404#c2 */ 59 | } 60 | 61 | /* Improve media defaults */ 62 | :where(img, svg, video, canvas, audio, iframe, embed, object) { 63 | display: block; 64 | } 65 | :where(img, svg, video) { 66 | block-size: auto; 67 | max-inline-size: 100%; 68 | } 69 | 70 | /* Remove stroke and set fill colour to the inherited font colour */ 71 | // :where(svg) { 72 | // stroke: none; 73 | // fill: currentColor; 74 | // } 75 | 76 | /* SVG's without a fill attribute */ 77 | // :where(svg):where(:not([fill])) { 78 | // /* Remove fill and set stroke colour to the inherited font colour */ 79 | // stroke: currentColor; 80 | // fill: none; 81 | // /* Rounded stroke */ 82 | // stroke-linecap: round; 83 | // stroke-linejoin: round; 84 | // } 85 | 86 | /* Set a size for SVG's without a width attribute */ 87 | :where(svg):where(:not([width])) { 88 | inline-size: 5rem; 89 | } 90 | 91 | /* Remove built-in form typography styles */ 92 | :where(input, button, textarea, select), 93 | :where(input[type="file"])::-webkit-file-upload-button { 94 | color: inherit; 95 | font: inherit; 96 | font-size: inherit; 97 | letter-spacing: inherit; 98 | word-spacing: inherit; 99 | } 100 | 101 | /* Change textarea resize to vertical only and block only if the browser supports that */ 102 | :where(textarea) { 103 | resize: vertical; 104 | } 105 | @supports (resize: block) { 106 | :where(textarea) { 107 | resize: block; 108 | } 109 | } 110 | 111 | /* Avoid text overflows */ 112 | :where(p, h1, h2, h3, h4, h5, h6) { 113 | overflow-wrap: break-word; 114 | } 115 | 116 | /* Fix h1 font size inside article, aside, nav, and section */ 117 | h1 { 118 | font-size: 2em; 119 | } 120 | 121 | /* Position list marker inside */ 122 | :where(ul, ol) { 123 | list-style-position: inside; 124 | } 125 | 126 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 127 | :where(ul, ol)[role="list"] { 128 | list-style: none; 129 | } 130 | 131 | /* More readable underline style for anchor tags without a class. This could be set on anchor tags globally, but it can cause conflicts. */ 132 | a:not([class]) { 133 | text-decoration-skip-ink: auto; 134 | } 135 | 136 | /* Make it clear that interactive elements are interactive */ 137 | :where(a[href], area, button, input, label[for], select, summary, textarea, [tabindex]:not([tabindex*="-"])) { 138 | cursor: pointer; 139 | touch-action: manipulation; 140 | } 141 | :where(input[type="file"]) { 142 | cursor: auto; 143 | } 144 | :where(input[type="file"])::-webkit-file-upload-button, 145 | :where(input[type="file"])::file-selector-button { 146 | cursor: pointer; 147 | } 148 | 149 | /* Animate focus outline */ 150 | @media (prefers-reduced-motion: no-preference) { 151 | :focus-visible { 152 | transition: outline-offset 145ms cubic-bezier(0.25, 0, 0.4, 1); 153 | } 154 | :where(:not(:active)):focus-visible { 155 | transition-duration: 0.25s; 156 | } 157 | } 158 | :where(:not(:active)):focus-visible { 159 | outline-offset: 5px; 160 | } 161 | 162 | /* Make sure users can't select button text */ 163 | :where(button, button[type], input[type="button"], input[type="submit"], input[type="reset"]), 164 | :where(input[type="file"])::-webkit-file-upload-button, 165 | :where(input[type="file"])::file-selector-button { 166 | -webkit-tap-highlight-color: transparent; 167 | -webkit-touch-callout: none; 168 | user-select: none; 169 | text-align: center; 170 | } 171 | 172 | /* Disabled cursor for disabled buttons */ 173 | :where(button, button[type], input[type="button"], input[type="submit"], input[type="reset"])[disabled] { 174 | cursor: not-allowed; 175 | } 176 | -------------------------------------------------------------------------------- /client/src/assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | 3 | $color-primary: #a888ff; 4 | $color-background: #1b1c21; 5 | -------------------------------------------------------------------------------- /client/src/assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | @use "reset"; 2 | @use "variables"; 3 | 4 | html, 5 | body, 6 | #root { 7 | height: 100%; 8 | width: 100%; 9 | } 10 | 11 | body { 12 | background-color: variables.$color-background; 13 | color: #fff; 14 | font: 1rem "Lato", sans-serif; 15 | overflow-x: hidden; 16 | } 17 | 18 | button { 19 | border-radius: 0.25rem; 20 | padding: 0.5rem 0.75rem; 21 | background-color: variables.$color-primary; 22 | color: #000; 23 | font-weight: 700; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/components/Avatar.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 |
10 | 11 | 56 | -------------------------------------------------------------------------------- /client/src/components/Cursor.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 |
55 | 56 | 59 | 63 | 64 | 65 |
{text}
66 |
67 | 68 | 108 | -------------------------------------------------------------------------------- /client/src/components/ErrorPage.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 | 18 | 19 |
20 |

{errorDetails.title}

21 |

{errorDetails.description}

22 |
23 |
24 |

Need help?

25 |
26 | 33 | 40 |
41 |
42 |
43 | 44 | 92 | -------------------------------------------------------------------------------- /client/src/components/Hyperbeam.svelte: -------------------------------------------------------------------------------- 1 | 90 | 91 | 92 |
93 |
94 |
95 | 96 | 106 | -------------------------------------------------------------------------------- /client/src/components/IconButton.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 25 | -------------------------------------------------------------------------------- /client/src/components/Invite.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 | 29 | -------------------------------------------------------------------------------- /client/src/components/Loading.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if showLoading} 12 |
13 | 14 | 19 | 20 |
Loading
21 |
22 | {/if} 23 | 24 | 37 | -------------------------------------------------------------------------------- /client/src/components/Members.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 | {#each $members as member} 42 | {#if !$room.state.isPasswordProtected || ($room.state.isPasswordProtected && member.isPasswordAuthenticated)} 43 | 44 |
setControl(member)} on:keypress={(e) => handleAvatarKeypress(e, member)}> 45 | 46 |
47 |
48 | {/if} 49 | {/each} 50 | 51 | {#if $room && $room.state.isPasswordProtected && $currentUser && !$currentUser.isPasswordAuthenticated} 52 | 59 | {/if} 60 |
61 | 62 | 79 | -------------------------------------------------------------------------------- /client/src/components/Toolbar.svelte: -------------------------------------------------------------------------------- 1 | 54 | 55 | {#if !isFullscreen} 56 |
57 |
58 | 59 |
60 | 61 |
62 | 63 | 64 | 65 | 68 | 69 | 70 | 71 | 72 | { 74 | window.location.href = inviteUrl; 75 | }} 76 | on:keypress={handleKeypress(() => { 77 | window.location.href = inviteUrl; 78 | })}> 79 | 80 | 83 | 84 | 85 | 86 | {#if $currentUser && $currentUser.isAuthenticated} 87 | 88 | 89 | 90 | 93 | 94 | 95 | 96 | {:else} 97 | 105 | {/if} 106 |
107 |
108 | {/if} 109 | 110 | 171 | -------------------------------------------------------------------------------- /client/src/components/Tooltip.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
(isVisible = true)} 11 | on:mouseover={() => (isVisible = true)} 12 | on:blur={() => (isVisible = false)} 13 | on:mouseout={() => (isVisible = false)}> 14 | {#if isVisible} 15 |
16 | {text} 17 |
18 | {/if} 19 | 20 |
21 | 22 | 40 | -------------------------------------------------------------------------------- /client/src/components/Volume.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | 26 | {#if isMuted} 27 | { 29 | isMuted = false; 30 | }} 31 | on:keypress={handleKeypress(() => { 32 | isMuted = false; 33 | })}> 34 | 35 | 38 | 39 | 40 | {:else} 41 | { 43 | isMuted = true; 44 | }} 45 | on:keypress={handleKeypress(() => { 46 | isMuted = true; 47 | })}> 48 | {#if volume > 0.5} 49 | 50 | 53 | 54 | {:else if volume > 0} 55 | 56 | 59 | 60 | {:else} 61 | 62 | 63 | 64 | {/if} 65 | 66 | {/if} 67 | 68 | { 75 | isMuted = false; 76 | }} 77 | on:change={() => { 78 | localStorage.setItem("volume", volume.toString()); 79 | }} /> 80 |
81 | 82 | 92 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import App from "./App.svelte"; 2 | 3 | const app = new App({ 4 | target: document.getElementById("root"), 5 | }); 6 | 7 | export default app; 8 | -------------------------------------------------------------------------------- /client/src/pages/Authorize.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /client/src/pages/Lander.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |
15 | 16 | 21 | 22 | 23 |

Browse the web with your community

24 |

Hyperbeam's Discord bot is the best way to browse any website together right from Discord.

25 | 26 | 33 | 40 | 41 |
    42 |
  • 43 | Want Hyperbeam's virtual browser in your app?
    44 | Check out the 45 | Hyperbeam API 46 | . 47 |
  • 48 |
  • 49 | Discord 50 |
  • 51 |
  • 52 | GitHub 53 |
  • 54 |
55 |
56 |
58 | 59 | 147 | -------------------------------------------------------------------------------- /client/src/pages/Room.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | {#await loadRoom()} 51 | 52 | {:then} 53 | {#if $room && $room.state.embedUrl} 54 |
55 | 56 | {#if vmNode} 57 | {#each $members as member} 58 | {#if member.cursor && $currentUser && member.id !== $currentUser.id} 59 | 60 | {/if} 61 | {/each} 62 | {/if} 63 | 64 |
65 | {:else} 66 | 67 | {/if} 68 | {/await} 69 | 70 | 100 | -------------------------------------------------------------------------------- /client/src/schemas/cursor.ts: -------------------------------------------------------------------------------- 1 | import { Schema, type } from "@colyseus/schema"; 2 | 3 | export class Cursor extends Schema { 4 | @type("number") x: number = 0; 5 | @type("number") y: number = 0; 6 | @type("string") message?: string; 7 | } 8 | 9 | export default Cursor; 10 | -------------------------------------------------------------------------------- /client/src/schemas/member.ts: -------------------------------------------------------------------------------- 1 | import { Schema, type } from "@colyseus/schema"; 2 | 3 | import { Cursor } from "./cursor"; 4 | 5 | export class Member extends Schema { 6 | @type("string") id: string; 7 | @type("string") hbId?: string; 8 | @type("string") name: string; 9 | @type("string") avatarUrl: string; 10 | @type("string") color: string = "#000000"; 11 | @type("string") control: "disabled" | "requesting" | "enabled" = "enabled"; 12 | @type("boolean") isAuthenticated: boolean = false; 13 | @type("boolean") isPasswordAuthenticated: boolean = false; 14 | @type(Cursor) cursor: Cursor; 15 | } 16 | 17 | export default Member; 18 | -------------------------------------------------------------------------------- /client/src/schemas/room.ts: -------------------------------------------------------------------------------- 1 | import { MapSchema, Schema, type } from "@colyseus/schema"; 2 | 3 | import { Member } from "./member"; 4 | 5 | export class RoomState extends Schema { 6 | @type({ map: Member }) members = new MapSchema(); 7 | @type("string") embedUrl?: string; 8 | @type("string") sessionId?: string; 9 | @type("string") ownerId: string; 10 | @type("boolean") isPasswordProtected: boolean = false; 11 | // Not synced 12 | password?: string; 13 | } 14 | 15 | export default RoomState; 16 | -------------------------------------------------------------------------------- /client/src/scripts/api.ts: -------------------------------------------------------------------------------- 1 | import { Client, Room } from "colyseus.js"; 2 | import { nanoid } from "nanoid"; 3 | import { get } from "svelte/store"; 4 | 5 | import type RoomState from "../schemas/room"; 6 | import { currentUser, extendedError, members, room, trackedCursor } from "../store"; 7 | 8 | const useSSL = import.meta.env.VITE_API_SERVER_BASE_URL.startsWith("https"); 9 | const hostname = `${import.meta.env.VITE_API_SERVER_BASE_URL.split("://")[1]}`; 10 | export const client = new Client({ hostname, useSSL, port: useSSL ? 443 : 80 }); 11 | 12 | interface AuthorizedUser { 13 | id: string; 14 | username: string; 15 | avatar: string; 16 | discriminator: string; 17 | email: string; 18 | token: string; 19 | } 20 | 21 | export async function parseDiscordResponse(code: string, state: string): Promise { 22 | if (state !== localStorage.getItem("state")) throw new Error("Invalid OAuth2 state"); 23 | localStorage.removeItem("state"); 24 | const response = await fetch(`${import.meta.env.VITE_API_SERVER_BASE_URL}/authorize/${code}`, { 25 | headers: { 26 | "Content-Type": "application/json", 27 | }, 28 | }); 29 | if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); 30 | const data: AuthorizedUser = (await response.json()) as AuthorizedUser; 31 | if (!data.id || !data.token) throw new Error("Unable to determine user"); 32 | localStorage.setItem("token", data.token); 33 | return data; 34 | } 35 | 36 | export const oauthUrl = (state: string) => 37 | `https://discord.com/oauth2/authorize?client_id=${import.meta.env.VITE_CLIENT_ID!}&redirect_uri=${encodeURIComponent( 38 | import.meta.env.VITE_CLIENT_BASE_URL!, 39 | )}%2Fauthorize&response_type=code&scope=identify%20email&state=${state}`; 40 | 41 | export function redirectToDiscord(redirectAfterAuth?: string) { 42 | const redirectRoute = redirectAfterAuth || localStorage.getItem("redirectAfterAuth"); 43 | if (redirectRoute) localStorage.setItem("redirectAfterAuth", redirectRoute); 44 | const state = nanoid(); 45 | localStorage.setItem("state", state); 46 | window.location.href = oauthUrl(state); 47 | } 48 | 49 | export type PartialRoom = { 50 | createdAt: string; 51 | endedAt: string | null; 52 | owner: { 53 | username: string; 54 | discriminator: string; 55 | }; 56 | active: boolean; 57 | }; 58 | 59 | export async function connect(url: string, initialAttempt = true) { 60 | console.log(`${initialAttempt ? "Connecting" : "Reconnecting"} ...`); 61 | let isConnected = false; 62 | let roomExists = undefined; 63 | let i = 1; 64 | while (!isConnected && roomExists !== false) { 65 | const sec = Math.min(i, 15); 66 | try { 67 | await join(url); 68 | isConnected = true; 69 | break; 70 | } catch (err) { 71 | if (roomExists === undefined) { 72 | const response = await fetch(`${import.meta.env.VITE_API_SERVER_BASE_URL}/info/${url}`); 73 | if (!response.ok) { 74 | roomExists = false; 75 | console.log(`Session ${url} not found`); 76 | extendedError.set({ 77 | code: response.status, 78 | title: `Session not found`, 79 | description: [ 80 | "The session you are trying to join does not exist.", 81 | "Please check the URL and try again.", 82 | ].join("\n"), 83 | }); 84 | break; 85 | } 86 | const roomInfo = (await response.json()) as PartialRoom; 87 | if (!roomInfo.active) { 88 | roomExists = false; 89 | console.log(`Session ${url} is not active`); 90 | extendedError.set({ 91 | code: response.status, 92 | title: "This session is not active", 93 | description: [ 94 | "Late to the party?", 95 | `Ask ${roomInfo.owner.username}#${roomInfo.owner.discriminator} for a new link or start a new session yourself.`, 96 | ].join("\n"), 97 | }); 98 | break; 99 | } else if (roomInfo.active) { 100 | roomExists = true; 101 | } 102 | } 103 | console.log(`Reconnect attempt ${i} failed. Retrying in ${sec} seconds.`); 104 | } 105 | await new Promise((resolve) => setTimeout(resolve, sec * 1000)); 106 | i++; 107 | } 108 | } 109 | 110 | const getToken = () => { 111 | const token = localStorage.getItem("token"); 112 | if (typeof token === "string" && token !== "undefined") { 113 | return token; 114 | } 115 | return null; 116 | }; 117 | 118 | const getDeviceId = () => { 119 | const deviceId = localStorage.getItem("deviceId"); 120 | if (typeof deviceId === "string" && deviceId !== "undefined") { 121 | return deviceId; 122 | } else { 123 | const newDeviceId = nanoid(); 124 | localStorage.setItem("deviceId", newDeviceId); 125 | return newDeviceId; 126 | } 127 | }; 128 | 129 | export async function join(url: string) { 130 | const roomInstance: Room = await client.joinById(url, { token: getToken(), deviceId: getDeviceId() }); 131 | if (get(room)) get(room).leave(); 132 | room.set(roomInstance); 133 | console.log(`Joined room ${roomInstance.roomId}`); 134 | await sendCursorUpdates(roomInstance).catch((err) => console.error(err)); 135 | members.subscribe((currentMembers) => { 136 | if (get(currentUser)) currentMembers.sort((a) => (a.id === get(currentUser).id ? -1 : 1)); 137 | }); 138 | members.set([...roomInstance.state.members.values()]); 139 | roomInstance.onStateChange((state) => { 140 | members.set([...state.members.values()]); 141 | }); 142 | roomInstance.onMessage("identify", (data: { id: string }) => { 143 | currentUser.set(get(members).find((m) => m.id === data.id)); 144 | }); 145 | roomInstance.onLeave(async (code) => { 146 | if (code >= 1001 && code <= 1015) { 147 | await connect(url, false); 148 | } 149 | }); 150 | return roomInstance; 151 | } 152 | 153 | let cursorInterval: number | undefined = undefined; 154 | 155 | export async function sendCursorUpdates(room: Room, interval = 40) { 156 | type WebSocketTransport = typeof room.connection.transport & { ws: WebSocket }; 157 | const transport = room.connection.transport as WebSocketTransport; 158 | 159 | if (transport.ws.readyState === WebSocket.OPEN) { 160 | console.log("Websocket connection open, sending cursor updates"); 161 | window.clearInterval(cursorInterval); 162 | cursorInterval = window.setInterval(async () => { 163 | // if (room.state.isPasswordProtected && !get(currentUser).isPasswordAuthenticated) return; 164 | if ( 165 | room.state.isPasswordProtected != null && 166 | ((room.state.isPasswordProtected && 167 | get(currentUser).isPasswordAuthenticated != null && 168 | get(currentUser).isPasswordAuthenticated) || 169 | !room.state.isPasswordProtected) 170 | ) { 171 | try { 172 | if (transport.ws.readyState !== WebSocket.OPEN) { 173 | window.clearInterval(cursorInterval); 174 | throw new Error("Websocket connection not open."); 175 | } else room.send("setCursor", get(trackedCursor)); 176 | } catch (err) { 177 | window.clearInterval(cursorInterval); 178 | } 179 | } 180 | }, interval); 181 | } else { 182 | window.clearInterval(cursorInterval); 183 | throw new Error("Websocket connection not open."); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /client/src/store.ts: -------------------------------------------------------------------------------- 1 | import type { HyperbeamEmbed } from "@hyperbeam/web"; 2 | import type { Room } from "colyseus.js"; 3 | import { writable } from "svelte/store"; 4 | 5 | import type Member from "./schemas/member"; 6 | import type RoomState from "./schemas/room"; 7 | 8 | export const hyperbeamEmbed = writable(); 9 | export const room = writable>(); 10 | 11 | export const members = writable([]); 12 | export const currentUser = writable(); 13 | export const trackedCursor = writable<{ x: number; y: number }>({ x: 0, y: 0 }); 14 | export const attemptSignIn = writable(false); 15 | 16 | export type ExtendedErrorType = { title: string; description: string; code?: number }; 17 | export const extendedError = writable(); 18 | -------------------------------------------------------------------------------- /client/src/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare global { 5 | interface ImportMetaEnv { 6 | readonly VITE_CLIENT_ID: string; 7 | readonly VITE_CLIENT_PORT: string; 8 | readonly VITE_CLIENT_BASE_URL: string; 9 | readonly VITE_CLIENT_SOCKET_URL: string; 10 | readonly VITE_API_SERVER_PORT: string; 11 | readonly VITE_API_SERVER_BASE_URL: string; 12 | readonly VITE_DISCORD_SUPPORT_SERVER: string; 13 | readonly VITE_GITHUB_URL: string; 14 | } 15 | 16 | interface ImportMeta { 17 | readonly env: ImportMetaEnv; 18 | } 19 | } 20 | 21 | export {}; 22 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "experimentalDecorators": true, 5 | "moduleResolution": "node", 6 | "target": "ESNext", 7 | "module": "ESNext", 8 | "isolatedModules": true, 9 | "sourceMap": true, 10 | "strict": false, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "composite": true, 15 | "outDir": "../dist/client", 16 | "rootDirs": ["./", "../shared"] 17 | }, 18 | "include": ["./**/*.ts", "./**/*.d.ts", "./**/*.svelte", "../shared/*.ts", "../shared/*.d.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | DISCORD_CLIENT_ID: string; 5 | DISCORD_CLIENT_SECRET: string; 6 | DISCORD_BOT_TOKEN: string; 7 | VITE_DISCORD_SUPPORT_SERVER: string; 8 | VITE_GITHUB_URL: string; 9 | HB_API_KEY: string; 10 | HB_API_ENV: string; 11 | VITE_CLIENT_ID: string; 12 | VITE_CLIENT_PORT: string; 13 | VITE_CLIENT_BASE_URL: string; 14 | VITE_API_SERVER_PORT: string; 15 | VITE_API_SERVER_BASE_URL: string; 16 | DATABASE_URL: string; 17 | } 18 | } 19 | } 20 | 21 | export {}; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperbeam-discord-bot", 3 | "version": "1.0.0", 4 | "description": "A Discord bot utilizing the Hyperbeam API", 5 | "main": "dist/index.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "pm2 start ./pm2.config.js", 9 | "bot": "npm run db:update && npm run bot:build && npm run bot:launch", 10 | "bot:nodb": "npm run bot:build && npm run bot:launch", 11 | "bot:build": "rimraf ./dist/bot && tsc --project ./bot", 12 | "bot:launch": "cd dist/bot && node index.js", 13 | "bot:dev": "npm run db:update && nodemon --watch ./bot --exec \"npm run bot:nodb\" --ext ts,js,json", 14 | "client": "npm run client:build && npm run client:serve", 15 | "client:dev": "npm run client:syncschemas && vite --host", 16 | "client:build": "npm run client:syncschemas && vite build", 17 | "client:preview": "vite preview", 18 | "client:serve": "dotenv -e .env -- cross-var sirv -H -s -p %VITE_CLIENT_PORT% dist/client", 19 | "client:syncschemas": "ts-node ./scripts/syncSchemas.ts", 20 | "db:migrate": "npx prisma migrate dev && npx prisma generate", 21 | "db:update": "npx prisma migrate deploy && npx prisma generate", 22 | "db:clearinactive": "ts-node ./scripts/clearInactiveUsers.ts", 23 | "envtypes": "npx dotenv-types-generator --file ./.env", 24 | "lint": "eslint . --ignore-path .gitignore", 25 | "lint:fix": "eslint . --ignore-path .gitignore --fix", 26 | "format": "prettier --plugin-search-dir=. --write **/*.{ts,js,json,svelte,css,scss,html} --ignore-path .gitignore", 27 | "type-check": "tsc --project bot/tsconfig.json --noEmit && tsc --project client/tsconfig.json --noEmit && svelte-check" 28 | }, 29 | "dependencies": { 30 | "@colyseus/schema": "^1.0.42", 31 | "@colyseus/ws-transport": "0.15.0-preview.2", 32 | "@hyperbeam/web": "^0.0.22", 33 | "@prisma/client": "^4.5.0", 34 | "@sveltejs/vite-plugin-svelte": "^1.1.0", 35 | "better-sqlite3": "^7.6.2", 36 | "colyseus": "0.15.0-preview.4", 37 | "colyseus.js": "0.15.0-preview.5", 38 | "cors": "^2.8.5", 39 | "cross-var": "^1.1.0", 40 | "discord-oauth2": "^2.10.1", 41 | "discord.js": "^14.6.0", 42 | "dotenv": "^16.0.3", 43 | "dotenv-cli": "^6.0.0", 44 | "express": "^4.18.2", 45 | "jsonwebtoken": "^8.5.1", 46 | "morgan": "^1.10.0", 47 | "nanoid": "3.3.4", 48 | "node-fetch": "~2.6.7", 49 | "perfect-cursors": "^1.0.5", 50 | "pretty-ms": "~7.0.1", 51 | "sass": "^1.55.0", 52 | "sirv-cli": "^2.0.2", 53 | "slash-create": "^5.10.0", 54 | "svelte": "^3.52.0", 55 | "svelte-navigator": "^3.2.2", 56 | "svelte-notifications": "^0.9.98", 57 | "svelte-preprocess": "^4.10.7", 58 | "vite": "^3.2.0", 59 | "vite-plugin-compression": "^0.5.1" 60 | }, 61 | "devDependencies": { 62 | "@tsconfig/svelte": "^3.0.0", 63 | "@types/better-sqlite3": "^7.6.2", 64 | "@types/body-parser": "^1.19.2", 65 | "@types/cors": "^2.8.12", 66 | "@types/express": "^4.17.14", 67 | "@types/jsonwebtoken": "^8.5.9", 68 | "@types/morgan": "^1.9.3", 69 | "@types/node": "^18.11.7", 70 | "@types/node-fetch": "~2.6.2", 71 | "@typescript-eslint/eslint-plugin": "^5.41.0", 72 | "@typescript-eslint/parser": "^5.41.0", 73 | "dotenv-types-generator": "^1.1.2", 74 | "eslint": "^8.26.0", 75 | "eslint-config-prettier": "^8.5.0", 76 | "eslint-plugin-import": "^2.26.0", 77 | "eslint-plugin-jsx-a11y": "^6.6.1", 78 | "eslint-plugin-prettier": "^4.2.1", 79 | "eslint-plugin-simple-import-sort": "^8.0.0", 80 | "eslint-plugin-svelte": "^2.11.0", 81 | "nodemon": "^2.0.20", 82 | "prettier": "^2.7.1", 83 | "prettier-plugin-svelte": "^2.8.0", 84 | "prisma": "^4.5.0", 85 | "rimraf": "^3.0.2", 86 | "svelte-check": "^2.9.2", 87 | "svelte-eslint-parser": "^0.18.4", 88 | "ts-node": "^10.9.1", 89 | "tslib": "^2.4.0", 90 | "typescript": "4.7.4" 91 | }, 92 | "prisma": { 93 | "schema": "bot/prisma/schema.prisma" 94 | }, 95 | "overrides": { 96 | "discord-api-types": "0.37.14" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pm2.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: "hyperbeam-bot", 5 | script: "npm run bot", 6 | max_restarts: 10, 7 | }, 8 | { 9 | name: "hyperbeam-client", 10 | script: "npm run client", 11 | max_restarts: 10, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Hyperbeam Discord Bot 2 | 3 | ![Discord online count](https://img.shields.io/discord/966073020336734308?style=flat) ![Issue count](https://img.shields.io/github/issues/hyperbeam/discord-bot?style=flat) ![License](https://img.shields.io/github/license/hyperbeam/discord-bot?style=flat) ![GitHub language count](https://img.shields.io/github/languages/count/hyperbeam/discord-bot) ![GitHub contributors](https://img.shields.io/github/contributors/hyperbeam/discord-bot) 4 | 5 | ![Hyperbeam bot in action](https://user-images.githubusercontent.com/10488070/203178490-e4a065ef-ef9d-47c0-9dbf-8feb3a4f92cb.png) 6 | 7 | Whether its studies, games, anime or more, the Hyperbeam Discord bot lets you enjoy the web together. With a full shared virtual browser at your fingertips, you can open any website and share the link with your friends for a quick and simple co-browsing experience. 8 | 9 | | [Add to server][invitelink] | [Get support][support] | 10 | | --------------------------- | ---------------------- | 11 | 12 | ## Features 13 | 14 | - **Get started in seconds**
Use the **/start** command, share the link and that’s it. No hassle or fuss involved. 15 | 16 | - **Works with any website**
Everyone sees the same video and hears the same music at the same time. 17 | 18 | - **You’re in control**
Just click or tap on a user’s picture to enable or disable control for them. 19 | 20 | - **Private by default**
Only the people you share the link with can access your room and see your browser. 21 | 22 | - **Make the web multiplayer**
Queue videos, navigate pages, and play games together with multi cursor support. 23 | 24 | - **Collaborate instantly**
Load websites at more than 1Gbps internet speed, no download or setup required. 25 | 26 | - **Faster than screen sharing**
Smooth, high resolution streams for everyone regardless of your data plan or bandwidth limits. 27 | 28 | - **Works great on mobile**
Click, tap, and type regardless of whether you’re on mobile, tablet or desktop. 29 | 30 | - **100% safe and secure**
Our browsers run in our industry standard datacenter and nothing ever touches your device. 31 | 32 | ## Setup 33 | 34 | - Run `npm install` to install all dependencies 35 | - Get your `HYPERBEAM_API_KEY` from and store it in the `.env` file in the project root folder. 36 | - Create a [Discord application](https://discord.com/developers/applications) and enable a bot user for the account. 37 | - Get your `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` and `DISCORD_BOT_TOKEN` from there and add it to the `.env` file. 38 | - Optionally, store a server ID as `DISCORD_DEVELOPMENT_GUILD_ID` to register guild commands instead of global commands for quicker development. 39 | - Copy the `DISCORD_CLIENT_ID` value to the `VITE_CLIENT_ID` env variable as well. 40 | - Define the `VITE_CLIENT_PORT` and the `API_SERVER_PORT` for serving the frontend client and the backend server respectively. 41 | - Set the `VITE_CLIENT_URL` to the URL that the frontend client is served at. (ex: `https://localhost:4000`) 42 | - Add the `VITE_CLIENT_URL` to the OAuth2 redirect URI list in your Discord application settings. 43 | - Set the `DATABASE_URL` to the relative path to the SQLite db (relative to the prisma schema file) 44 | 45 | ## Development notes 46 | 47 | - Update typings with `npm run envtypes` after modifying the `.env` file structure 48 | - Generate migration files with `npm run db:migrate` and commit them after changing the database schema 49 | - Lint and fix issues before committing with `npm run lint:fix` 50 | 51 | ## Scripts 52 | 53 | ### Deploying 54 | 55 | - `npm start` 56 | Builds and starts a PM2 instance with both the bot/API and the frontend client processes 57 | - `npm run bot` 58 | Builds and starts the bot and the API server 59 | - `npm run client` 60 | Builds and starts the frontend client server 61 | 62 | ### Building/Compiling 63 | 64 | - `npm run bot:build` 65 | Builds the bot to the `dist/bot` folder 66 | - `npm run client:build` 67 | Builds the frontend client to the `dist/client` folder 68 | 69 | ### Development 70 | 71 | - `npm run bot:dev` 72 | Launches the bot in development mode and hot-reloads on file changes 73 | - `npm run client:dev` 74 | Launches the frontend client server without typechecking 75 | - `npm run lint` (or `lint:fix`) 76 | Lints (and optionally fixes) code style, formatting and linting errors with the ESLint config 77 | - `npm run envtypes` 78 | Generates typings from the `.env` file for typedefs in code 79 | 80 | ## Managing the database 81 | 82 | - `npx prisma db push` 83 | Push current schema onto the database and generate a new client 84 | - `npx prisma generate` 85 | Generate a new database client 86 | - `npx prisma studio` 87 | Browse through database contents in your browser 88 | 89 | ## Recommended VSCode plugins 90 | 91 | - [DotENV](https://marketplace.visualstudio.com/items?itemName=mikestead.dotenv) 92 | - [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 93 | - [Prisma](https://marketplace.visualstudio.com/items?itemName=Prisma.prisma) 94 | 95 | [invitelink]: https://discord.com/api/oauth2/authorize?client_id=983910226489126932&redirect_uri=https%3A%2F%2Fbot.hyperbeam.com%2Fauthorize&response_type=code&scope=identify+email+bot+applications.commands&permissions=277062470720 96 | [support]: https://discord.gg/D78RsGfQjq 97 | -------------------------------------------------------------------------------- /scripts/clearInactiveUsers.ts: -------------------------------------------------------------------------------- 1 | import database from "../bot/classes/database"; 2 | 3 | const deleteInactiveUsers = async (days = 30) => { 4 | const day = 1000 * 60 * 60 * 24; 5 | const inactiveUsers = await database.user.findMany({ 6 | where: { 7 | updatedAt: { 8 | lte: new Date(Date.now() - days * day), 9 | }, 10 | }, 11 | }); 12 | if (!inactiveUsers.length) return console.log("No inactive users found."); 13 | for (const user of inactiveUsers) { 14 | // Delete user from database 15 | await database.user.delete({ 16 | where: { 17 | id: user.id, 18 | }, 19 | }); 20 | console.log(`Deleted user ${user.id}`); 21 | 22 | // Delete session from database 23 | // const result = await database.session.deleteMany({ 24 | // where: { 25 | // ownerId: user.id, 26 | // }, 27 | // }); 28 | // console.log(`Deleted ${result.count} sessions for user ${user.id}`); 29 | } 30 | }; 31 | 32 | deleteInactiveUsers(30); 33 | -------------------------------------------------------------------------------- /scripts/syncSchemas.ts: -------------------------------------------------------------------------------- 1 | import { copyFile, readdir, rm } from "fs/promises"; 2 | import { resolve } from "path"; 3 | 4 | // simple script to sync schema files between bot and client 5 | 6 | const botDir = resolve(__dirname, "../bot"); 7 | const clientDir = resolve(__dirname, "../client"); 8 | 9 | readdir(`${clientDir}/src/schemas`).then(async (files) => { 10 | for (const file of files) { 11 | console.log(`Deleting schema ${file}`); 12 | await rm(resolve(`${clientDir}/src/schemas/${file}`), { force: true }).catch((err) => console.error(err)); 13 | } 14 | readdir(`${botDir}/schemas/`).then(async (files) => { 15 | for (const file of files) { 16 | const source = resolve(`${botDir}/schemas/${file}`); 17 | const destination = resolve(`${clientDir}/src/schemas/${file}`); 18 | console.log(`Copying ${file} to ${destination}`); 19 | await copyFile(source, destination).catch((err) => console.error(err)); 20 | } 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "rootDir": "./", 5 | "outDir": "./dist" 6 | }, 7 | "files": [], 8 | "references": [ 9 | { 10 | "path": "./bot/" 11 | }, 12 | { 13 | "path": "./client/" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import dotenv from "dotenv"; 3 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 4 | import sveltePreprocess from "svelte-preprocess"; 5 | import viteCompression from "vite-plugin-compression"; 6 | 7 | dotenv.config({ path: "./.env" }); 8 | const port = parseInt(process.env.VITE_CLIENT_PORT || "4000", 10); 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | plugins: [ 13 | svelte({ 14 | preprocess: sveltePreprocess({ 15 | typescript: true, 16 | }), 17 | }), 18 | viteCompression(), 19 | ], 20 | root: "./client/", 21 | server: { 22 | port, 23 | }, 24 | preview: { 25 | port, 26 | }, 27 | publicDir: "./public", 28 | build: { 29 | outDir: "../dist/client", 30 | emptyOutDir: true, 31 | }, 32 | }); 33 | --------------------------------------------------------------------------------