├── backend ├── static │ └── .gitkeep ├── migrations │ ├── .gitkeep │ ├── 2021-01-22-190245_create_users │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-26-201959_create_sounds │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-23-110157_create_guildsettings │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-26-214847_create_soundfile │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-26-184228_create_authtokens │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-24-102829_create_randominfixes │ │ ├── down.sql │ │ └── up.sql │ └── 00000000000000_diesel_initial_setup │ │ ├── down.sql │ │ └── up.sql ├── Rocket.toml ├── diesel.toml ├── src │ ├── db │ │ ├── mod.rs │ │ ├── schema.rs │ │ └── models.rs │ ├── discord │ │ ├── mod.rs │ │ ├── connector.rs │ │ ├── management.rs │ │ ├── client.rs │ │ └── commands.rs │ ├── audio_utils.rs │ ├── main.rs │ ├── api │ │ ├── utils.rs │ │ ├── mod.rs │ │ └── events.rs │ └── file_handling.rs └── Cargo.toml ├── frontend ├── src │ ├── assets │ │ └── .gitkeep │ ├── app │ │ ├── settings │ │ │ ├── sound-manager │ │ │ │ ├── sound-delete-confirm │ │ │ │ │ ├── sound-delete-confirm.component.scss │ │ │ │ │ ├── sound-delete-confirm.component.html │ │ │ │ │ └── sound-delete-confirm.component.ts │ │ │ │ ├── sound-details │ │ │ │ │ ├── sound-details.component.scss │ │ │ │ │ ├── sound-details.component.ts │ │ │ │ │ └── sound-details.component.html │ │ │ │ ├── can-deactivate-sound-manager.guard.ts │ │ │ │ ├── sound-manager.component.scss │ │ │ │ └── sound-manager.component.html │ │ │ ├── user-settings │ │ │ │ ├── user-settings.component.scss │ │ │ │ ├── user-settings.component.ts │ │ │ │ └── user-settings.component.html │ │ │ ├── random-infixes │ │ │ │ ├── random-infixes.component.scss │ │ │ │ ├── random-infixes.component.html │ │ │ │ └── random-infixes.component.ts │ │ │ ├── unsaved-changes-box │ │ │ │ ├── unsaved-changes-box.component.html │ │ │ │ ├── unsaved-changes-box.component.scss │ │ │ │ └── unsaved-changes-box.component.ts │ │ │ ├── settings.component.scss │ │ │ ├── guild-settings │ │ │ │ ├── guild-settings.component.scss │ │ │ │ ├── can-deactivate-guild-settings.guard.ts │ │ │ │ ├── guild-settings.component.ts │ │ │ │ └── guild-settings.component.html │ │ │ ├── settings.component.ts │ │ │ └── settings.component.html │ │ ├── app.component.scss │ │ ├── keybind-generator │ │ │ ├── searchable-sound-select │ │ │ │ ├── searchable-sound-select.component.scss │ │ │ │ ├── searchable-sound-select.component.html │ │ │ │ └── searchable-sound-select.component.ts │ │ │ ├── keycombination-input │ │ │ │ ├── key-combination-input.component.html │ │ │ │ ├── key-combination-input.component.scss │ │ │ │ └── key-combination-input.component.ts │ │ │ ├── keybind-generator.component.scss │ │ │ └── keybind-generator.component.html │ │ ├── soundboard │ │ │ ├── event-log-dialog │ │ │ │ ├── event-log-dialog.component.scss │ │ │ │ ├── event-log-dialog.component.ts │ │ │ │ └── event-log-dialog.component.html │ │ │ ├── soundboard-button │ │ │ │ ├── soundboard-button.component.html │ │ │ │ ├── soundboard-button.component.scss │ │ │ │ └── soundboard-button.component.ts │ │ │ ├── soundboard.component.scss │ │ │ ├── soundboard.component.html │ │ │ └── soundboard.component.ts │ │ ├── volume-slider │ │ │ ├── volume-slider.component.scss │ │ │ ├── volume-slider.component.html │ │ │ └── volume-slider.component.ts │ │ ├── app.component.html │ │ ├── footer │ │ │ ├── footer.component.scss │ │ │ ├── footer.component.ts │ │ │ └── footer.component.html │ │ ├── login │ │ │ ├── login.component.ts │ │ │ ├── login.component.scss │ │ │ └── login.component.html │ │ ├── guild-name.pipe.ts │ │ ├── guards │ │ │ └── guild-permission.guard.ts │ │ ├── event-description.pipe.ts │ │ ├── header │ │ │ ├── header.component.ts │ │ │ ├── header.component.scss │ │ │ └── header.component.html │ │ ├── common │ │ │ └── scroll-into-view.directive.ts │ │ ├── services │ │ │ ├── auth-interceptor.service.ts │ │ │ ├── guild-settings.service.ts │ │ │ ├── events.service.ts │ │ │ ├── app-settings.service.ts │ │ │ ├── recorder.service.ts │ │ │ ├── api.service.ts │ │ │ └── sounds.service.ts │ │ ├── app.component.ts │ │ ├── data-load │ │ │ ├── data-load-error.component.ts │ │ │ └── data-load.directive.ts │ │ ├── recorder │ │ │ ├── recorder.component.scss │ │ │ ├── recorder.component.html │ │ │ └── recorder.component.ts │ │ ├── app-routing.module.ts │ │ └── app.module.ts │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── styles │ │ ├── overrides.scss │ │ └── _variables.scss │ ├── main.ts │ ├── test.ts │ ├── index.html │ ├── styles.scss │ └── polyfills.ts ├── .husky │ └── pre-commit ├── .prettierignore ├── .prettierrc ├── proxy.config.js ├── e2e │ ├── tsconfig.json │ ├── src │ │ ├── app.po.ts │ │ └── app.e2e-spec.ts │ └── protractor.conf.js ├── tsconfig.app.json ├── .editorconfig ├── tsconfig.spec.json ├── .gitignore ├── tsconfig.json ├── karma.conf.js ├── package.json ├── .eslintrc.json └── angular.json ├── .vscode └── settings.json ├── .dockerignore ├── .gitattributes ├── doc └── images │ └── screenshots │ ├── dashboard.png │ ├── guild-sounds.png │ └── guild-settings.png ├── .gitignore ├── docker-compose.yml ├── LICENSE ├── .github └── workflows │ ├── lint.yaml │ └── build.yaml └── Dockerfile /backend/static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pretty-quick --staged 2 | ng lint 3 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /coverage 3 | /.angular/cache 4 | -------------------------------------------------------------------------------- /backend/migrations/2021-01-22-190245_create_users/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users -------------------------------------------------------------------------------- /backend/migrations/2021-01-26-201959_create_sounds/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE sounds -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": ["./frontend"] 3 | } 4 | -------------------------------------------------------------------------------- /backend/migrations/2021-01-23-110157_create_guildsettings/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE guilds -------------------------------------------------------------------------------- /backend/migrations/2021-01-26-214847_create_soundfile/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE soundfiles -------------------------------------------------------------------------------- /backend/migrations/2021-01-26-184228_create_authtokens/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE authtokens -------------------------------------------------------------------------------- /backend/migrations/2021-01-24-102829_create_randominfixes/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE randominfixes -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-delete-confirm/sound-delete-confirm.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /backend/target 2 | /backend/data 3 | /frontend/** 4 | !/frontend/dist/discord-soundboard-bot/** -------------------------------------------------------------------------------- /frontend/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dominikks/discord-soundboard-bot/HEAD/frontend/src/favicon.ico -------------------------------------------------------------------------------- /backend/Rocket.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | address = "0.0.0.0" 3 | port = 8000 4 | ## Allow bigger sound files to be uploaded 5 | limits = { file = "10MiB" } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.jpeg filter=lfs diff=lfs merge=lfs -text 2 | *.png filter=lfs diff=lfs merge=lfs -text 3 | *.jpg filter=lfs diff=lfs merge=lfs -text 4 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "htmlWhitespaceSensitivity": "strict" 6 | } 7 | -------------------------------------------------------------------------------- /backend/diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/db/schema.rs" 6 | -------------------------------------------------------------------------------- /doc/images/screenshots/dashboard.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:efa21d759bfaafab886e0cbd55af99388d4dac2ede70bdefd922ac96f5fb838f 3 | size 127255 4 | -------------------------------------------------------------------------------- /doc/images/screenshots/guild-sounds.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bdac8d44c0e89162cd4fff76951d04ab345bba192bd79d04f4c1376d27693e69 3 | size 117960 4 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | max-width: 100vw; 6 | overflow-x: hidden; 7 | } 8 | -------------------------------------------------------------------------------- /doc/images/screenshots/guild-settings.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:044c5289fe58109bcfafe43582c39a9f74d6bf1a6bba5add663f61f7ff1fb147 3 | size 140730 4 | -------------------------------------------------------------------------------- /backend/migrations/2021-01-22-190245_create_users/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id NUMERIC PRIMARY KEY NOT NULL, 3 | last_login TIMESTAMP NOT NULL, 4 | constraint id_nonnegative check (id >= 0) 5 | ) -------------------------------------------------------------------------------- /backend/migrations/2021-01-24-102829_create_randominfixes/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE randominfixes ( 2 | guild_id NUMERIC NOT NULL, 3 | infix VARCHAR(32) NOT NULL, 4 | display_name VARCHAR(32) NOT NULL, 5 | PRIMARY KEY(guild_id, infix) 6 | ) -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/searchable-sound-select/searchable-sound-select.component.scss: -------------------------------------------------------------------------------- 1 | :host ::ng-deep ngx-mat-select-search input { 2 | color: white !important; 3 | } 4 | 5 | mat-icon { 6 | vertical-align: middle; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/event-log-dialog/event-log-dialog.component.scss: -------------------------------------------------------------------------------- 1 | .no-events { 2 | font-style: italic; 3 | padding: 0 24px; 4 | } 5 | 6 | .mat-mdc-cell { 7 | padding: 0 4px; 8 | vertical-align: middle; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/settings/user-settings/user-settings.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | flex: 1; 3 | } 4 | 5 | .settings-container { 6 | padding: 15px; 7 | } 8 | 9 | mat-checkbox { 10 | display: block; 11 | margin-bottom: 8px; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/app/volume-slider/volume-slider.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | mat-slider { 7 | // Make sure the thumb label appears over the toolbar 8 | z-index: 3; 9 | flex-grow: 1; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/proxy.config.js: -------------------------------------------------------------------------------- 1 | const PROXY_CONFIG = [ 2 | { 3 | context: ['/api'], 4 | target: 'http://localhost:8000', 5 | secure: false, 6 | logLevel: 'debug', 7 | changeOrigin: true, 8 | }, 9 | ]; 10 | 11 | module.exports = PROXY_CONFIG; 12 | -------------------------------------------------------------------------------- /backend/migrations/2021-01-26-184228_create_authtokens/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE authtokens ( 2 | user_id NUMERIC PRIMARY KEY NOT NULL, 3 | token VARCHAR(32) UNIQUE NOT NULL, 4 | creation_time TIMESTAMP NOT NULL, 5 | FOREIGN KEY(user_id) REFERENCES users(id) 6 | ON DELETE CASCADE 7 | ) -------------------------------------------------------------------------------- /backend/migrations/2021-01-23-110157_create_guildsettings/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE guildsettings ( 2 | id NUMERIC PRIMARY KEY NOT NULL, 3 | user_role_id NUMERIC, 4 | moderator_role_id NUMERIC, 5 | target_max_volume REAL NOT NULL DEFAULT 0, 6 | target_mean_volume REAL NOT NULL DEFAULT -13 7 | ) -------------------------------------------------------------------------------- /frontend/src/app/volume-slider/volume-slider.component.html: -------------------------------------------------------------------------------- 1 | volume_up 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../out-tsc/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": ["jasmine", "jasminewd2", "node"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | $footer-color: rgba(white, 0.6); 2 | 3 | footer { 4 | text-align: center; 5 | font-size: 0.8rem; 6 | padding: 0.5rem 0; 7 | color: $footer-color; 8 | 9 | a { 10 | color: $footer-color; 11 | 12 | &:hover { 13 | text-decoration: underline; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/app/settings/random-infixes/random-infixes.component.scss: -------------------------------------------------------------------------------- 1 | mat-card { 2 | margin-bottom: 8px; 3 | max-width: 1500px; 4 | } 5 | 6 | .infix-list-item { 7 | display: flex; 8 | flex-wrap: wrap; 9 | align-items: center; 10 | margin: 8px; 11 | 12 | mat-form-field { 13 | flex: 1; 14 | margin-right: 0.4em; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "files": ["src/test.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/styles/overrides.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | a { 8 | color: $primary; 9 | text-decoration: none; 10 | } 11 | 12 | input[type='number'] { 13 | -moz-appearance: textfield; 14 | } 15 | 16 | .mat-expansion-panel-header-title, 17 | .mat-expansion-panel-header-description { 18 | flex-basis: 0; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ApiService } from '../services/api.service'; 3 | 4 | @Component({ 5 | selector: 'app-login', 6 | templateUrl: './login.component.html', 7 | styleUrls: ['./login.component.scss'], 8 | }) 9 | export class LoginComponent { 10 | constructor(protected apiService: ApiService) {} 11 | } 12 | -------------------------------------------------------------------------------- /backend/migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /backend/target 2 | /backend/data 3 | .env 4 | .volumes 5 | 6 | # IDEs and editors 7 | /.idea 8 | .project 9 | .classpath 10 | .c9/ 11 | *.launch 12 | .settings/ 13 | *.sublime-workspace 14 | 15 | # IDE - VSCode 16 | .vscode/* 17 | !.vscode/settings.json 18 | !.vscode/tasks.json 19 | !.vscode/launch.json 20 | !.vscode/extensions.json 21 | .history/* 22 | 23 | # System Files 24 | .DS_Store 25 | Thumbs.db 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Development Sandbox 2 | version: "3.8" 3 | services: 4 | db: 5 | image: postgres 6 | restart: unless-stopped 7 | ports: 8 | - 5432:5432 9 | environment: 10 | - POSTGRES_PASSWORD 11 | volumes: 12 | - ./.volumes/db/data:/var/lib/postgresql/data 13 | 14 | adminer: 15 | image: adminer 16 | restart: unless-stopped 17 | ports: 18 | - 8080:8080 19 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-delete-confirm/sound-delete-confirm.component.html: -------------------------------------------------------------------------------- 1 |

Confirm deletion

2 |
You are about to delete the sound "{{ data.sound.name }}". This action is irreversible. Are you sure?
3 |
4 | 5 | 6 |
7 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /frontend/src/app/guild-name.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { ApiService } from './services/api.service'; 3 | 4 | @Pipe({ 5 | name: 'guildName', 6 | }) 7 | export class GuildNamePipe implements PipeTransform { 8 | constructor(private apiService: ApiService) {} 9 | 10 | transform(guildId: string) { 11 | return this.apiService.user().guilds.find(guild => guild.id === guildId).name; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/keycombination-input/key-combination-input.component.html: -------------------------------------------------------------------------------- 1 |
2 | {{ keyCombination.isControl ? 'Ctrl+' : '' }}{{ keyCombination.isAlt ? 'Alt+' : '' }}{{ keyCombination.key }} 5 | Enter key combination 6 |
7 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/keycombination-input/key-combination-input.component.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .keybind-input { 4 | width: 250px; 5 | padding: 5px 10px; 6 | border: 2px solid grey; 7 | transition: border-color 0.2s; 8 | border-radius: 5px; 9 | user-select: none; 10 | 11 | &:focus { 12 | border-color: $primary; 13 | } 14 | 15 | &.invalid:not(:focus) { 16 | border-color: $warn; 17 | } 18 | } 19 | 20 | .placeholder { 21 | opacity: 0.6; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/app/guards/guild-permission.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { CanActivateFn, Router } from '@angular/router'; 3 | import { ApiService } from '../services/api.service'; 4 | 5 | export const guildPermissionGuard: CanActivateFn = (route, _state) => { 6 | const guildId = route.params.guildId; 7 | const user = inject(ApiService).user(); 8 | 9 | return user?.guilds.find(guild => guild.id === guildId).role !== 'user' ? true : inject(Router).parseUrl('/settings'); 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/app/settings/user-settings/user-settings.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { AppSettingsService } from 'src/app/services/app-settings.service'; 3 | 4 | @Component({ 5 | templateUrl: './user-settings.component.html', 6 | styleUrls: ['./user-settings.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | }) 9 | export class UserSettingsComponent { 10 | constructor(public settingsService: AppSettingsService) {} 11 | } 12 | -------------------------------------------------------------------------------- /backend/migrations/2021-01-26-214847_create_soundfile/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE soundfiles ( 2 | sound_id INTEGER PRIMARY KEY NOT NULL, 3 | file_name VARCHAR(64) NOT NULL, 4 | max_volume REAL NOT NULL, 5 | mean_volume REAL NOT NULL, 6 | length REAL NOT NULL, 7 | uploaded_by_user_id NUMERIC, 8 | uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY(sound_id) REFERENCES sounds(id) 10 | ON DELETE RESTRICT, 11 | FOREIGN KEY(uploaded_by_user_id) REFERENCES users(id) 12 | ON DELETE SET NULL 13 | ) -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # misc 18 | /.angular/cache 19 | /.sass-cache 20 | /connect.lock 21 | /coverage 22 | /libpeerconnection.log 23 | npm-debug.log 24 | yarn-error.log 25 | testem.log 26 | /typings 27 | 28 | -------------------------------------------------------------------------------- /frontend/src/app/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { ApiService } from '../services/api.service'; 3 | 4 | @Component({ 5 | selector: 'app-footer', 6 | templateUrl: './footer.component.html', 7 | styleUrls: ['./footer.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class FooterComponent { 11 | get info() { 12 | return this.apiService.appInfo(); 13 | } 14 | 15 | constructor(private apiService: ApiService) {} 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 9 | teardown: { destroyAfterEach: false }, 10 | }); 11 | -------------------------------------------------------------------------------- /backend/migrations/2021-01-26-201959_create_sounds/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE sounds ( 2 | id SERIAL PRIMARY KEY, 3 | guild_id NUMERIC NOT NULL, 4 | name VARCHAR(64) NOT NULL, 5 | category VARCHAR(64) NOT NULL, 6 | created_by_user_id NUMERIC, 7 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | last_edited_by_user_id NUMERIC, 9 | last_edited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | volume_adjustment REAL, 11 | FOREIGN KEY(created_by_user_id) REFERENCES users(id) 12 | ON DELETE SET NULL, 13 | FOREIGN KEY(last_edited_by_user_id) REFERENCES users(id) 14 | ON DELETE SET NULL 15 | ) -------------------------------------------------------------------------------- /frontend/src/app/settings/user-settings/user-settings.component.html: -------------------------------------------------------------------------------- 1 |
2 |

User settings

3 | Automatically join the channel you are in when playing a sound. 9 | Show debug information when playing sounds. 12 |
13 | -------------------------------------------------------------------------------- /frontend/src/app/volume-slider/volume-slider.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { AppSettingsService } from '../services/app-settings.service'; 3 | 4 | @Component({ 5 | selector: 'app-volume-slider', 6 | templateUrl: './volume-slider.component.html', 7 | styleUrls: ['./volume-slider.component.scss'], 8 | }) 9 | export class VolumeSliderComponent { 10 | get settings() { 11 | return this.settingsService.settings; 12 | } 13 | 14 | constructor(private settingsService: AppSettingsService) {} 15 | 16 | formatLabel(value: number): string { 17 | return `${value.toFixed(0)} %`; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Soundboard 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "module": "ESNext", 15 | "lib": ["ES2022", "dom"], 16 | "useDefineForClassFields": false 17 | }, 18 | "angularCompilerOptions": { 19 | "strictInjectionParameters": true, 20 | "strictInputAccessModifiers": true, 21 | "strictTemplates": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /frontend/src/app/event-description.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { Event } from './services/events.service'; 3 | 4 | @Pipe({ name: 'eventDescription' }) 5 | export class EventDescriptionPipe implements PipeTransform { 6 | transform(event: Event): string { 7 | switch (event.type) { 8 | case 'PlaybackStarted': 9 | return `played the sound '${event.soundName}'`; 10 | case 'PlaybackStopped': 11 | return 'stopped the playback'; 12 | case 'RecordingSaved': 13 | return 'saved a recording'; 14 | case 'JoinedChannel': 15 | return `connected the soundboard to channel '${event.channelName}'`; 16 | case 'LeftChannel': 17 | return 'disconnected the soundboard'; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | use diesel::PgConnection; 2 | use rocket::Build; 3 | use rocket::Rocket; 4 | use rocket_sync_db_pools::database; 5 | 6 | pub mod models; 7 | pub mod schema; 8 | 9 | #[database("postgres_database")] 10 | pub struct DbConn(PgConnection); 11 | 12 | pub async fn run_db_migrations(rocket: Rocket) -> Rocket { 13 | use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; 14 | 15 | const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); 16 | 17 | DbConn::get_one(&rocket) 18 | .await 19 | .expect("Database connection") 20 | .run(|c| { 21 | c.run_pending_migrations(MIGRATIONS) 22 | .expect("Diesel migrations"); 23 | }) 24 | .await; 25 | 26 | rocket 27 | } 28 | -------------------------------------------------------------------------------- /frontend/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('discord-soundboard-bot app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain( 20 | jasmine.objectContaining({ 21 | level: logging.Level.SEVERE, 22 | } as logging.Entry) 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/app/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | import { catchError } from 'rxjs/operators'; 3 | import { EMPTY } from 'rxjs'; 4 | import { ApiService } from '../services/api.service'; 5 | 6 | @Component({ 7 | selector: 'app-header', 8 | templateUrl: './header.component.html', 9 | styleUrls: ['./header.component.scss'], 10 | }) 11 | export class HeaderComponent { 12 | @Input({ required: true }) pageTitle: string; 13 | @Input() showSidenavToggle = false; 14 | 15 | @Output() toggleSidenav = new EventEmitter(); 16 | 17 | constructor(protected apiService: ApiService) {} 18 | 19 | logout() { 20 | this.apiService 21 | .logout() 22 | .pipe(catchError(() => EMPTY)) 23 | .subscribe(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-delete-confirm/sound-delete-confirm.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | import { Sound } from 'src/app/services/sounds.service'; 4 | 5 | @Component({ 6 | templateUrl: './sound-delete-confirm.component.html', 7 | styleUrls: ['./sound-delete-confirm.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class SoundDeleteConfirmComponent { 11 | constructor(private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: { sound: Sound }) {} 12 | 13 | confirm() { 14 | this.dialogRef.close(true); 15 | } 16 | 17 | abort() { 18 | this.dialogRef.close(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/app/common/scroll-into-view.directive.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Directive, ElementRef, Input } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[appScrollIntoView]', 5 | }) 6 | export class ScrollIntoViewDirective implements AfterViewInit { 7 | private _enabled = false; 8 | 9 | @Input() 10 | appScrollIntoViewOnAdd = false; 11 | 12 | @Input() set appScrollIntoView(value: boolean) { 13 | if (value && this._enabled) { 14 | this.elementRef.nativeElement.scrollIntoView({ behavior: 'smooth' }); 15 | } 16 | } 17 | 18 | constructor(private elementRef: ElementRef) {} 19 | 20 | ngAfterViewInit() { 21 | this._enabled = true; 22 | 23 | if (this.appScrollIntoViewOnAdd) { 24 | this.elementRef.nativeElement.scrollIntoView({ behavior: 'smooth' }); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/login/login.component.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | :host { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | min-height: 100vh; 8 | } 9 | 10 | .filler { 11 | flex: 1; 12 | } 13 | 14 | mat-card { 15 | min-width: 300px; 16 | padding: 0; 17 | overflow: hidden; // force rounded corners onto children 18 | } 19 | 20 | .login-title { 21 | background-color: $primary; 22 | color: rgba(black, 0.87); 23 | height: 3em; 24 | display: flex; 25 | align-items: center; 26 | } 27 | 28 | .login-title, 29 | .login-content { 30 | padding: 0 10px; 31 | } 32 | 33 | .login-button { 34 | width: 100%; 35 | margin-bottom: 10px; 36 | 37 | .button-content { 38 | display: flex; 39 | align-items: center; 40 | gap: 8px; 41 | } 42 | 43 | svg { 44 | height: 2em; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | // Custom Theming for Angular Material 2 | @use '@angular/material' as mat; 3 | // For more information: https://material.angular.io/guide/theming 4 | // Plus imports for other components in your app. 5 | @import '~ress/ress.css'; 6 | @import 'variables'; 7 | @import 'overrides'; 8 | 9 | // Include the common styles for Angular Material. We include this here so that you only 10 | // have to load a single css file for Angular Material in your app. 11 | // Be sure that you only ever include this mixin once! 12 | @include mat.all-component-typographies(); 13 | @include mat.core(); 14 | 15 | // Include theme styles for core and each component used in your app. 16 | // Alternatively, you can import and @include the theme mixins for each component 17 | // that you are using. 18 | @include mat.all-component-themes($soundboard-theme); 19 | -------------------------------------------------------------------------------- /frontend/src/app/settings/unsaved-changes-box/unsaved-changes-box.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
You have unsaved changes
5 |
6 | 7 |
8 | 11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /frontend/src/app/services/auth-interceptor.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable, throwError } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { ApiService } from './api.service'; 6 | 7 | @Injectable() 8 | export class AuthInterceptorService implements HttpInterceptor { 9 | constructor(private apiService: ApiService) {} 10 | 11 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 12 | return next.handle(req).pipe( 13 | catchError((error: HttpErrorResponse) => { 14 | if (error.status === 401) { 15 | this.apiService.user.set(null); 16 | } 17 | 18 | console.error(error); 19 | return throwError(() => error); 20 | }) 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/app/settings/settings.component.scss: -------------------------------------------------------------------------------- 1 | .settings-container { 2 | height: 100vh; 3 | width: 100vw; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | .is-mobile app-header { 9 | position: fixed; 10 | z-index: 2; 11 | width: 100%; 12 | } 13 | 14 | mat-sidenav-container { 15 | flex: 1; 16 | } 17 | .is-mobile mat-sidenav-container { 18 | flex: 1 0 auto; 19 | } 20 | 21 | .sidenav-list { 22 | width: 250px; 23 | } 24 | 25 | mat-nav-list { 26 | a, 27 | mat-list-item { 28 | &.active-nav-link { 29 | background-color: rgba(white, 0.08); 30 | } 31 | &:hover { 32 | background-color: rgba(white, 0.12); 33 | } 34 | } 35 | 36 | .guild-menu-item { 37 | padding-left: 48px; 38 | } 39 | } 40 | 41 | mat-sidenav-content { 42 | display: flex; 43 | flex-direction: column; 44 | } 45 | 46 | img[matListItemIcon] { 47 | border-radius: 50%; 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/app/settings/guild-settings/guild-settings.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: relative; 3 | flex: 1; 4 | } 5 | 6 | .guild-container { 7 | padding: 16px; 8 | } 9 | 10 | .title { 11 | // Needed for italic text to not be cut off 12 | padding: 0 4px; 13 | margin: 0 -4px; 14 | 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | 18 | .guild-name { 19 | font-style: italic; 20 | } 21 | } 22 | 23 | .section-title { 24 | mat-icon, 25 | span { 26 | vertical-align: middle; 27 | } 28 | } 29 | 30 | .setting-input-wrapper { 31 | display: flex; 32 | align-items: baseline; 33 | 34 | mat-form-field { 35 | margin-right: 0.5em; 36 | } 37 | } 38 | 39 | .save-state-idle-icon { 40 | @keyframes fadeout { 41 | 0% { 42 | opacity: 1; 43 | } 44 | 100% { 45 | opacity: 0; 46 | } 47 | } 48 | 49 | animation: fadeout 1s ease-out 1s both; 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/soundboard-button/soundboard-button.component.html: -------------------------------------------------------------------------------- 1 | 9 | 18 | -------------------------------------------------------------------------------- /frontend/src/app/settings/unsaved-changes-box/unsaved-changes-box.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | :host { 4 | position: fixed; 5 | bottom: 0; 6 | right: 0; 7 | } 8 | 9 | .wrapper { 10 | padding: 16px; 11 | 12 | mat-card { 13 | @include mat.elevation(16); 14 | } 15 | } 16 | 17 | mat-card { 18 | height: 64px; 19 | } 20 | 21 | mat-card-content { 22 | display: flex; 23 | flex-direction: row; 24 | align-items: baseline; 25 | 26 | .flex-filler { 27 | flex: 1; 28 | } 29 | 30 | .text-wrapper { 31 | margin-right: 2em; 32 | } 33 | 34 | .mat-h3 { 35 | margin-bottom: 0; 36 | } 37 | } 38 | 39 | .save-button-container { 40 | position: relative; 41 | 42 | .spinner-container { 43 | position: absolute; 44 | top: 0; 45 | bottom: 0; 46 | left: 0; 47 | right: 0; 48 | z-index: 1; 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-details/sound-details.component.scss: -------------------------------------------------------------------------------- 1 | mat-panel-title { 2 | display: flex; 3 | align-items: center; 4 | 5 | mat-icon { 6 | margin-left: 0.25em; 7 | } 8 | } 9 | 10 | mat-form-field { 11 | margin-right: 0.5em; 12 | } 13 | 14 | .spinner-button-container { 15 | position: relative; 16 | 17 | .spinner-container { 18 | position: absolute; 19 | top: 0; 20 | bottom: 0; 21 | left: 0; 22 | right: 0; 23 | z-index: 1; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | } 29 | 30 | .hidden { 31 | visibility: collapse; 32 | } 33 | 34 | .statistics-wrapper { 35 | display: flex; 36 | flex-wrap: wrap; 37 | align-items: flex-start; 38 | margin-top: 1em; 39 | 40 | h3 { 41 | min-width: 200px; 42 | } 43 | 44 | .statistics-container > div { 45 | display: flex; 46 | justify-content: space-between; 47 | min-width: 300px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { forkJoin, of, Subject } from 'rxjs'; 3 | import { catchError } from 'rxjs/operators'; 4 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 5 | import { ApiService, AppInfo, User } from './services/api.service'; 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | templateUrl: './app.component.html', 10 | styleUrls: ['./app.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class AppComponent { 14 | readonly data$ = forkJoin([this.apiService.loadAppInfo(), this.apiService.loadUser().pipe(catchError(() => of(null)))]); 15 | readonly loadedData$ = new Subject<[AppInfo, User]>(); 16 | 17 | constructor(protected apiService: ApiService) { 18 | this.loadedData$.pipe(takeUntilDestroyed()).subscribe(data => { 19 | this.apiService.appInfo.set(data[0]); 20 | this.apiService.user.set(data[1]); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: ['./src/**/*.e2e-spec.ts'], 13 | capabilities: { 14 | browserName: 'chrome', 15 | }, 16 | directConnect: true, 17 | baseUrl: 'http://localhost:4200/', 18 | framework: 'jasmine', 19 | jasmineNodeOpts: { 20 | showColors: true, 21 | defaultTimeoutInterval: 30000, 22 | print: function () {}, 23 | }, 24 | onPrepare() { 25 | require('ts-node').register({ 26 | project: require('path').join(__dirname, './tsconfig.json'), 27 | }); 28 | jasmine.getEnv().addReporter( 29 | new SpecReporter({ 30 | spec: { 31 | displayStacktrace: StacktraceOption.PRETTY, 32 | }, 33 | }) 34 | ); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/app/settings/guild-settings/can-deactivate-guild-settings.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | import { CanDeactivateFn } from '@angular/router'; 4 | import { GuildSettingsComponent } from './guild-settings.component'; 5 | 6 | export const canDeactivateGuildSettingsGuard: CanDeactivateFn = (component, _route, _state) => { 7 | const snackBar = inject(MatSnackBar); 8 | 9 | if (component.randomInfixesHasChanges()) { 10 | snackBar.open('You have unsaved changes. Please save or discard them before leaving this component.'); 11 | return false; 12 | } 13 | 14 | if ( 15 | component.userIsSaving() === 'saving' || 16 | component.moderatorIsSaving() === 'saving' || 17 | component.maxVolumeIsSaving() === 'saving' || 18 | component.meanVolumeIsSaving() === 'saving' || 19 | component.randomInfixIsSaving() 20 | ) { 21 | snackBar.open('You cannot leave this component while saving.'); 22 | return false; 23 | } 24 | 25 | return true; 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/searchable-sound-select/searchable-sound-select.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | stop Stop 7 | voicemail Record last 60s 8 | 9 | {{ sound.name }} 10 | 11 | 12 | stop Stop 13 | voicemail Record last 60s 14 | {{ getSoundName(selectedCommand) }} 15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/can-deactivate-sound-manager.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | import { CanDeactivateFn } from '@angular/router'; 4 | import { SoundManagerComponent } from './sound-manager.component'; 5 | 6 | export const canDeactivateSoundManagerGuard: CanDeactivateFn = (component, _route, _state) => { 7 | const snackBar = inject(MatSnackBar); 8 | 9 | if (component.isSaving()) { 10 | snackBar.open('You cannot leave this component while saving'); 11 | return false; 12 | } 13 | if (component.isUploading()) { 14 | snackBar.open('You cannot leave this component while uploading'); 15 | return false; 16 | } 17 | if (component.hasChanges()) { 18 | snackBar.open('There are sounds with outstanding changes. Please save or discard them before continuing.'); 19 | return false; 20 | } 21 | if (component.isProcessing()) { 22 | snackBar.open('There are sounds currently being processed. Please wait until that is finished.'); 23 | return false; 24 | } 25 | 26 | return true; 27 | }; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dominik Kus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/app/settings/unsaved-changes-box/unsaved-changes-box.component.ts: -------------------------------------------------------------------------------- 1 | import { animate, state, style, transition, trigger } from '@angular/animations'; 2 | import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; 3 | 4 | @Component({ 5 | selector: 'app-unsaved-changes-box', 6 | templateUrl: './unsaved-changes-box.component.html', 7 | styleUrls: ['./unsaved-changes-box.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | animations: [ 10 | trigger('enterLeave', [ 11 | state('*', style({ transform: 'translateY(0)' })), 12 | transition(':enter', [style({ transform: 'translateY(100%)' }), animate('200ms ease-out')]), 13 | transition(':leave', [animate('200ms ease-in', style({ transform: 'translateY(100%)' }))]), 14 | ]), 15 | ], 16 | }) 17 | export class UnsavedChangesBoxComponent { 18 | @Input({ required: true }) hasChanges: boolean; 19 | @Input({ required: true }) isSaving: boolean; 20 | @Input() disabled = false; 21 | 22 | @Output() saveChanges = new EventEmitter(); 23 | @Output() discardChanges = new EventEmitter(); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/app/services/guild-settings.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { RandomInfix } from './api.service'; 4 | 5 | export interface GuildSettings { 6 | userRoleId: string; 7 | moderatorRoleId: string; 8 | targetMeanVolume: number; 9 | targetMaxVolume: number; 10 | roles: Map; 11 | } 12 | 13 | @Injectable({ providedIn: 'root' }) 14 | export class GuildSettingsService { 15 | constructor(private http: HttpClient) {} 16 | 17 | updateRandomInfixes(guildId: string, infixes: Omit[]) { 18 | return this.http.put(`/api/guilds/${encodeURIComponent(guildId)}/random-infixes`, infixes, { responseType: 'text' }); 19 | } 20 | 21 | loadGuildSettings(guildId: string) { 22 | return this.http.get(`/api/guilds/${encodeURIComponent(guildId)}/settings`); 23 | } 24 | 25 | updateGuildSettings(guildId: string, guildSettings: Partial>) { 26 | return this.http.put(`/api/guilds/${encodeURIComponent(guildId)}/settings`, guildSettings, { responseType: 'text' }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/discord-soundboard-bot'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/app/services/events.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NgZone } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | type BaseEventData = { guildId: string; userName: string; userAvatarUrl: string; timestamp: number }; 5 | 6 | export type Event = ( 7 | | { type: 'PlaybackStarted'; soundName: string } 8 | | { type: 'PlaybackStopped' } 9 | | { type: 'RecordingSaved' } 10 | | { type: 'JoinedChannel'; channelName: string } 11 | | { type: 'LeftChannel' } 12 | ) & 13 | BaseEventData; 14 | 15 | @Injectable({ 16 | providedIn: 'root', 17 | }) 18 | export class EventsService { 19 | constructor(private zone: NgZone) {} 20 | 21 | getEventStream(guildId: string): Observable { 22 | return new Observable(observer => { 23 | const eventSource = new EventSource(`/api/${guildId}/events`); 24 | 25 | eventSource.onmessage = event => { 26 | this.zone.run(() => { 27 | observer.next(JSON.parse(event.data) as Event); 28 | }); 29 | }; 30 | 31 | eventSource.onerror = error => { 32 | this.zone.run(() => { 33 | observer.error(error); 34 | }); 35 | }; 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | // Define the palettes for your theme using the Material Design palettes available in palette.scss 4 | // (imported above). For each palette, you can optionally specify a default, lighter, and darker 5 | // hue. Available color palettes: https://material.io/design/color/ 6 | $soundboard-primary: mat.define-palette(mat.$orange-palette); 7 | $soundboard-accent: mat.define-palette(mat.$light-blue-palette); 8 | 9 | // The warn palette is optional (defaults to red). 10 | $soundboard-warn: mat.define-palette(mat.$red-palette); 11 | 12 | // Create the theme object. A theme consists of configurations for individual 13 | // theming systems such as "color" or "typography". 14 | $soundboard-theme: mat.define-dark-theme( 15 | ( 16 | color: ( 17 | primary: $soundboard-primary, 18 | accent: $soundboard-accent, 19 | warn: $soundboard-warn, 20 | ), 21 | ) 22 | ); 23 | 24 | // Save the colors separately for easy access 25 | $primary: mat.get-color-from-palette($soundboard-primary); 26 | $accent: mat.get-color-from-palette($soundboard-accent); 27 | $warn: mat.get-color-from-palette($soundboard-warn); 28 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/keycombination-input/key-combination-input.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | export interface KeyCombination { 4 | key: string; 5 | isControl: boolean; 6 | isAlt: boolean; 7 | } 8 | 9 | @Component({ 10 | selector: 'app-key-combination-input', 11 | templateUrl: './key-combination-input.component.html', 12 | styleUrls: ['./key-combination-input.component.scss'], 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | }) 15 | export class KeyCombinationInputComponent { 16 | @Input({ required: true }) keyCombination: KeyCombination; 17 | @Output() keyCombinationChange = new EventEmitter(); 18 | 19 | onKey(event: KeyboardEvent) { 20 | // Ignore Control, Alt, Shift, Tab, Windows-Key 21 | if (['Alt', 'Control', 'Shift', 'Tab', 'OS'].includes(event.key)) { 22 | return; 23 | } 24 | 25 | event.preventDefault(); 26 | 27 | const keyCombination: KeyCombination = { 28 | key: event.key.toUpperCase(), 29 | isAlt: event.altKey, 30 | isControl: event.ctrlKey, 31 | }; 32 | this.keyCombinationChange.emit(keyCombination); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/app/data-load/data-load-error.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | template: ` 5 |
6 | error 7 |
There was an error loading the required data. Please try again later.
8 |
9 | 10 | `, 11 | styles: [ 12 | ` 13 | @use '@angular/material' as mat; 14 | 15 | :host { 16 | @include mat.elevation(4); 17 | background-color: rgba(255, 0, 0, 0.2); 18 | border-radius: 5px; 19 | 20 | display: block; 21 | max-width: 400px; 22 | margin: 16px auto; 23 | padding: 16px; 24 | } 25 | 26 | .error-box { 27 | display: flex; 28 | align-items: center; 29 | margin-bottom: 8px; 30 | gap: 8px; 31 | 32 | mat-icon { 33 | flex-shrink: 0; 34 | } 35 | } 36 | `, 37 | ], 38 | changeDetection: ChangeDetectionStrategy.OnPush, 39 | }) 40 | export class DataLoadErrorComponent { 41 | @Output() retry = new EventEmitter(); 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/header/header.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | :host { 4 | @include mat.elevation(8); 5 | // make sure the shadow is rendered 6 | z-index: 2; 7 | } 8 | 9 | .app-title { 10 | color: inherit; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | } 14 | 15 | .spacer { 16 | flex: 1; 17 | } 18 | 19 | a.mat-mdc-button { 20 | &.active, 21 | &:hover { 22 | background-color: rgba(0, 0, 0, 0.07); 23 | } 24 | } 25 | 26 | .second-row { 27 | display: none; 28 | 29 | height: 36px; 30 | padding: 0; 31 | overflow-x: auto; 32 | overflow-y: hidden; 33 | 34 | .mat-mdc-button { 35 | flex: 1; 36 | min-width: 150px; 37 | } 38 | } 39 | 40 | @media screen and (max-width: 750px) { 41 | .first-row-navigation { 42 | display: none; 43 | } 44 | 45 | .second-row { 46 | display: flex; 47 | } 48 | } 49 | 50 | .avatar-button-wrapper { 51 | height: 40px; 52 | width: 40px; 53 | margin-left: 8px; 54 | 55 | .avatar-button { 56 | border-radius: 50%; 57 | width: 100%; 58 | height: 100%; 59 | cursor: pointer; 60 | 61 | img { 62 | height: 100%; 63 | width: 100%; 64 | object-fit: cover; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/event-log-dialog/event-log-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Inject, signal } from '@angular/core'; 2 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | import { Observable } from 'rxjs'; 4 | import { Event } from 'src/app/services/events.service'; 5 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 6 | import { MatSnackBar } from '@angular/material/snack-bar'; 7 | 8 | @Component({ 9 | templateUrl: './event-log-dialog.component.html', 10 | styleUrls: ['./event-log-dialog.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class EventLogDialogComponent { 14 | readonly displayedColumns = ['timestamp', 'icon', 'user', 'description']; 15 | readonly events = signal([]); 16 | 17 | constructor(@Inject(MAT_DIALOG_DATA) events: Observable, snackBar: MatSnackBar) { 18 | events.pipe(takeUntilDestroyed()).subscribe({ 19 | next: event => this.events.mutate(events => events.push(event)), 20 | error: () => snackBar.open('Failed to fetch events.', 'Damn', { duration: undefined }), 21 | }); 22 | } 23 | 24 | trackByIndex(index: number, _item: Event) { 25 | return index; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/settings/random-infixes/random-infixes.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | Display Name 7 | 8 | Empty name is not allowed 9 | 10 | 11 | Search Term 12 | 13 | Empty infix is not allowed 14 | 15 | 16 |
17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "discord-soundboard-bot" 3 | version = "0.4.0" 4 | authors = ["Dominik Kus "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | bigdecimal = "0.4" 9 | diesel = { version = "2.1", default-features = false, features = ["postgres", "numeric"] } 10 | diesel_migrations = "2.1" 11 | dotenv = "0.15" 12 | lazy_static = "1.4" 13 | oauth2 = "4.4" 14 | rand = "0.8" 15 | regex = "1.9" 16 | reqwest = { version = "0.11", features = ["json"] } 17 | rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] } 18 | rocket_sync_db_pools = { version = "=0.1.0-rc.3", features = ["diesel_postgres_pool"] } 19 | sanitize-filename = "0.5" 20 | serde = "1.0" 21 | serde_json = "1.0" 22 | serde_derive = "1.0" 23 | serde_with = "3.3" 24 | serenity = { version = "0.11", features = ["cache", "standard_framework", "voice", "voice-model", "rustls_backend"] } 25 | songbird = { version = "0.3" } 26 | tokio = { version = "1.32", features = ["rt", "rt-multi-thread", "macros", "process"] } 27 | tracing = "0.1" 28 | tracing-futures = "0.2" 29 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 30 | # Needed for Diesel Postgres linking for MUSL 31 | # https://github.com/emk/rust-musl-builder#making-diesel-work 32 | openssl = "*" 33 | -------------------------------------------------------------------------------- /backend/migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint app 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | frontend-lint: 7 | name: Lint frontend 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Set up Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | 18 | - name: Install dependencies 19 | run: npm ci 20 | working-directory: frontend 21 | 22 | - name: Run linter 23 | run: npm run lint 24 | working-directory: frontend 25 | 26 | - name: Run prettier 27 | run: npm run format:check 28 | working-directory: frontend 29 | 30 | backend-lint: 31 | name: Lint backend 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v3 35 | 36 | - name: Setup toolchain 37 | uses: actions-rs/toolchain@v1 38 | with: 39 | toolchain: stable 40 | components: rustfmt, clippy 41 | 42 | - name: Run formatter 43 | uses: actions-rs/cargo@v1 44 | with: 45 | command: fmt 46 | args: --manifest-path backend/Cargo.toml --check 47 | 48 | - name: Run clippy 49 | uses: actions-rs/cargo@v1 50 | with: 51 | command: clippy 52 | args: --manifest-path backend/Cargo.toml 53 | -------------------------------------------------------------------------------- /backend/src/discord/mod.rs: -------------------------------------------------------------------------------- 1 | use serenity::client::Cache; 2 | use serenity::client::Context; 3 | use serenity::http::CacheHttp as SerenityCacheHttp; 4 | use serenity::http::Http; 5 | use serenity::CacheAndHttp; 6 | use std::sync::Arc; 7 | 8 | pub mod client; 9 | mod commands; 10 | pub mod connector; 11 | pub mod management; 12 | pub mod recorder; 13 | 14 | /// Instead of the built-in serenity struct, we use this 15 | #[derive(Clone)] 16 | pub struct CacheHttp { 17 | pub cache: Arc, 18 | pub http: Arc, 19 | } 20 | 21 | impl SerenityCacheHttp for CacheHttp { 22 | fn http(&self) -> &Http { 23 | &self.http 24 | } 25 | fn cache(&self) -> Option<&Arc> { 26 | Some(&self.cache) 27 | } 28 | } 29 | 30 | impl From<&Context> for CacheHttp { 31 | fn from(ctx: &Context) -> Self { 32 | CacheHttp { 33 | cache: ctx.cache.clone(), 34 | http: ctx.http.clone(), 35 | } 36 | } 37 | } 38 | 39 | impl From<&Arc> for CacheHttp { 40 | fn from(cachehttp: &Arc) -> Self { 41 | CacheHttp { 42 | cache: cachehttp.cache.clone(), 43 | http: cachehttp.http.clone(), 44 | } 45 | } 46 | } 47 | 48 | impl AsRef for CacheHttp { 49 | fn as_ref(&self) -> &Cache { 50 | self.cache.as_ref() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/soundboard-button/soundboard-button.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: relative; 3 | } 4 | 5 | .main-button { 6 | height: 100%; 7 | min-width: 100%; 8 | object-fit: cover; 9 | 10 | &:not(:only-child) { 11 | padding-right: 72px; 12 | } 13 | 14 | ::ng-deep .mdc-button__label { 15 | display: flex; 16 | align-self: stretch; 17 | flex-direction: column; 18 | text-align: center; 19 | min-width: 100%; 20 | 21 | z-index: 0; // prevents the label from overlapping the preview button or filters 22 | } 23 | } 24 | 25 | .preview-button { 26 | position: absolute; 27 | right: 16px; 28 | top: 50%; 29 | transform: translateY(-50%); 30 | 31 | &.mat-unthemed { 32 | color: rgba(232, 230, 227, 0.38); 33 | } 34 | } 35 | 36 | .sound-name, 37 | .sound-source { 38 | padding: 0 8px; 39 | } 40 | 41 | .sound-name { 42 | flex: 1; 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | font-weight: 500; 47 | margin-top: 0.5em; 48 | margin-bottom: 1.5em; 49 | max-height: 100%; 50 | word-break: break-word; 51 | 52 | ::ng-deep .mat-icon { 53 | flex: none; 54 | margin-right: 0.5em; 55 | } 56 | } 57 | 58 | .sound-source { 59 | opacity: 0.3; 60 | 61 | white-space: nowrap; 62 | overflow: hidden; 63 | text-overflow: ellipsis; 64 | direction: rtl; 65 | 66 | position: absolute; 67 | bottom: 0; 68 | left: 0; 69 | right: 0; 70 | 71 | .no-category { 72 | font-style: italic; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/app/services/app-settings.service.ts: -------------------------------------------------------------------------------- 1 | import { effect, Injectable, signal } from '@angular/core'; 2 | import { toObservable } from '@angular/core/rxjs-interop'; 3 | import { ApiService } from './api.service'; 4 | 5 | const STORAGE_KEY = 'soundboard-settings'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class AppSettingsService { 11 | readonly settings = { 12 | guildId: signal(null), 13 | localVolume: signal(100), 14 | soundCategories: signal([]), 15 | debug: signal(false), 16 | autoJoin: signal(true), 17 | }; 18 | 19 | constructor(apiService: ApiService) { 20 | const saved = localStorage.getItem(STORAGE_KEY); 21 | if (saved) { 22 | try { 23 | const data = JSON.parse(saved); 24 | for (const key in data) { 25 | if (key in this.settings) { 26 | this.settings[key].set(data[key]); 27 | } 28 | } 29 | } catch {} 30 | } 31 | 32 | effect(() => { 33 | const transformedObject = Object.fromEntries(Object.entries(this.settings).map(([key, value]) => [key, value()])); 34 | try { 35 | localStorage.setItem(STORAGE_KEY, JSON.stringify(transformedObject)); 36 | } catch {} 37 | }); 38 | 39 | toObservable(apiService.user).subscribe(user => { 40 | const guildId = this.settings.guildId(); 41 | if (guildId == null && user?.guilds.length > 0) { 42 | this.settings.guildId.set(user.guilds[0].id); 43 | } 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-details/sound-details.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, EventEmitter, Input, Output } from '@angular/core'; 2 | import { SoundEntry } from '../sound-manager.component'; 3 | 4 | type VolumeAdjustmentMode = 'auto' | 'manual'; 5 | 6 | @Component({ 7 | selector: 'app-sound-details', 8 | templateUrl: './sound-details.component.html', 9 | styleUrls: ['./sound-details.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | }) 12 | export class SoundDetailsComponent { 13 | @Input({ required: true }) soundEntry: SoundEntry; 14 | @Input({ required: true }) isBusy: boolean; 15 | @Input({ required: true }) isPlaying: boolean; 16 | 17 | @Output() playClick = new EventEmitter(); 18 | @Output() deleteClick = new EventEmitter(); 19 | @Output() replaceSoundFile = new EventEmitter(); 20 | 21 | get sound() { 22 | return this.soundEntry.sound; 23 | } 24 | 25 | readonly volumeAdjustmentMode = computed(() => (this.soundEntry.sound().volumeAdjustment == null ? 'auto' : 'manual')); 26 | 27 | updateVolumeAdjustmentMode(mode: VolumeAdjustmentMode) { 28 | if (mode === 'auto') { 29 | this.soundEntry.mutateSound({ volumeAdjustment: null }); 30 | } else { 31 | this.soundEntry.mutateSound({ volumeAdjustment: 0 }); 32 | } 33 | } 34 | 35 | onImportFileChange(event: Event) { 36 | const files = (event.target as HTMLInputElement).files; 37 | if (files.length === 1) { 38 | this.replaceSoundFile.emit(files[0]); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/app/recorder/recorder.component.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | :host { 4 | flex: 1; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .max-width { 10 | max-width: 1500px; 11 | margin: 0 auto; 12 | width: 100%; 13 | } 14 | 15 | mat-toolbar { 16 | height: auto; 17 | min-height: 64px; 18 | font-size: 14px; 19 | justify-content: flex-end; 20 | margin-bottom: 16px; 21 | 22 | .toolbar-content { 23 | @extend .max-width; 24 | display: flex; 25 | align-items: center; 26 | justify-content: flex-end; 27 | flex-wrap: wrap; 28 | 29 | > * { 30 | margin: 5px; 31 | } 32 | } 33 | } 34 | 35 | p { 36 | margin-left: 16px; 37 | margin-right: 16px; 38 | } 39 | 40 | main { 41 | flex: 1; 42 | 43 | mat-checkbox { 44 | margin-left: 2em; 45 | } 46 | 47 | mat-divider { 48 | margin: 1em 0; 49 | 50 | &:first-of-type { 51 | margin-top: 0; 52 | } 53 | } 54 | 55 | mat-slider { 56 | display: block; 57 | } 58 | 59 | button:not(:last-child) { 60 | margin-right: 1em; 61 | } 62 | 63 | .sound-channel-controls { 64 | margin-bottom: 16px; 65 | } 66 | 67 | .button-row { 68 | display: flex; 69 | flex-wrap: wrap; 70 | 71 | .filler { 72 | flex: 1; 73 | } 74 | } 75 | } 76 | 77 | :host ::ng-deep { 78 | mat-expansion-panel { 79 | mat-expansion-panel-header > .mat-content { 80 | justify-content: space-between; 81 | } 82 | 83 | .mat-content > mat-panel-title, 84 | .mat-content > mat-panel-description { 85 | flex: 0 0 auto; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/searchable-sound-select/searchable-sound-select.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; 2 | import { Sound } from 'src/app/services/sounds.service'; 3 | import Fuse from 'fuse.js'; 4 | import { KeyCommand } from '../keybind-generator.component'; 5 | 6 | @Component({ 7 | selector: 'app-searchable-sound-select', 8 | templateUrl: './searchable-sound-select.component.html', 9 | styleUrls: ['./searchable-sound-select.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | }) 12 | export class SearchableSoundSelectComponent implements OnChanges { 13 | @Input({ required: true }) sounds: Sound[]; 14 | @Input({ required: true }) selectedCommand: KeyCommand; 15 | @Output() selectedCommandChange = new EventEmitter(); 16 | 17 | soundsFuse: Fuse; 18 | soundSearchFilter = ''; 19 | filteredSounds: Sound[]; 20 | 21 | ngOnChanges(changes: SimpleChanges) { 22 | if ('sounds' in changes) { 23 | this.soundsFuse = new Fuse(this.sounds, { keys: ['name'] }); 24 | this.updateFilter(); 25 | } 26 | } 27 | 28 | updateFilter() { 29 | if (this.sounds == null) { 30 | return; 31 | } 32 | 33 | if (this.soundSearchFilter.length > 0) { 34 | this.filteredSounds = this.soundsFuse.search(this.soundSearchFilter).map(res => res.item); 35 | } else { 36 | this.filteredSounds = this.sounds; 37 | } 38 | } 39 | 40 | getSoundName(command: KeyCommand) { 41 | return command != null && typeof command !== 'string' ? command.name : ''; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/event-log-dialog/event-log-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Event Log

2 | 3 |
No events to display
4 | 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
{{ event.timestamp * 1000 | date : 'mediumTime' }} 10 | play_arrow 11 | stop 12 | fiber_manual_record 13 | login 14 | logout 15 | {{ event.userName }}{{ event | eventDescription }}
26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/keybind-generator.component.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .max-width { 4 | max-width: 1500px; 5 | margin: 0 auto; 6 | padding: 0 16px; 7 | width: 100%; 8 | } 9 | 10 | mat-toolbar { 11 | height: auto; 12 | min-height: 64px; 13 | margin-bottom: 16px; 14 | 15 | .toolbar-content { 16 | @extend .max-width; 17 | display: flex; 18 | justify-content: flex-end; 19 | align-items: center; 20 | flex-wrap: wrap; 21 | } 22 | 23 | .hidden { 24 | display: none; 25 | } 26 | } 27 | 28 | .button-row { 29 | display: flex; 30 | justify-content: space-between; 31 | flex-wrap: wrap; 32 | gap: 8px; 33 | } 34 | 35 | .auth-token-row { 36 | display: flex; 37 | align-items: center; 38 | gap: 8px; 39 | } 40 | 41 | .table-wrapper { 42 | margin-bottom: 16px; 43 | overflow-x: auto; 44 | } 45 | 46 | app-searchable-sound-select, 47 | mat-select { 48 | margin: 0 8px; 49 | width: 100%; 50 | } 51 | 52 | .drag-handle { 53 | margin-top: 2px; 54 | cursor: pointer; 55 | opacity: 0.8; 56 | } 57 | 58 | .mat-column-dragDrop { 59 | flex: 0 0 60px; 60 | } 61 | 62 | .mat-column-keyCombination { 63 | flex: 0 0 300px; 64 | } 65 | 66 | .mat-column-discordServer { 67 | flex: 0 0 200px; 68 | } 69 | 70 | .mat-column-command { 71 | flex: 1 0 100px; 72 | } 73 | 74 | .mat-column-actions { 75 | flex: 0 0 auto; 76 | } 77 | 78 | .cdk-drag-preview { 79 | border-radius: 4px; 80 | box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); 81 | } 82 | 83 | .cdk-drag-placeholder { 84 | opacity: 0; 85 | } 86 | 87 | .cdk-drag-animating, 88 | .cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-placeholder) { 89 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 90 | } 91 | -------------------------------------------------------------------------------- /backend/src/db/schema.rs: -------------------------------------------------------------------------------- 1 | table! { 2 | authtokens (user_id) { 3 | user_id -> Numeric, 4 | token -> Varchar, 5 | creation_time -> Timestamp, 6 | } 7 | } 8 | 9 | table! { 10 | guildsettings (id) { 11 | id -> Numeric, 12 | user_role_id -> Nullable, 13 | moderator_role_id -> Nullable, 14 | target_max_volume -> Float4, 15 | target_mean_volume -> Float4, 16 | } 17 | } 18 | 19 | table! { 20 | randominfixes (guild_id, infix) { 21 | guild_id -> Numeric, 22 | infix -> Varchar, 23 | display_name -> Varchar, 24 | } 25 | } 26 | 27 | table! { 28 | soundfiles (sound_id) { 29 | sound_id -> Int4, 30 | file_name -> Varchar, 31 | max_volume -> Float4, 32 | mean_volume -> Float4, 33 | length -> Float4, 34 | uploaded_by_user_id -> Nullable, 35 | uploaded_at -> Timestamp, 36 | } 37 | } 38 | 39 | table! { 40 | sounds (id) { 41 | id -> Int4, 42 | guild_id -> Numeric, 43 | name -> Varchar, 44 | category -> Varchar, 45 | created_by_user_id -> Nullable, 46 | created_at -> Timestamp, 47 | last_edited_by_user_id -> Nullable, 48 | last_edited_at -> Timestamp, 49 | volume_adjustment -> Nullable, 50 | } 51 | } 52 | 53 | table! { 54 | users (id) { 55 | id -> Numeric, 56 | last_login -> Timestamp, 57 | } 58 | } 59 | 60 | joinable!(authtokens -> users (user_id)); 61 | joinable!(soundfiles -> sounds (sound_id)); 62 | joinable!(soundfiles -> users (uploaded_by_user_id)); 63 | 64 | allow_tables_to_appear_in_same_query!( 65 | authtokens, 66 | guildsettings, 67 | randominfixes, 68 | soundfiles, 69 | sounds, 70 | users, 71 | ); 72 | -------------------------------------------------------------------------------- /frontend/src/app/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { MediaMatcher } from '@angular/cdk/layout'; 2 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core'; 3 | import { MatSidenav } from '@angular/material/sidenav'; 4 | import { Router } from '@angular/router'; 5 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 6 | import { ApiService } from '../services/api.service'; 7 | 8 | @Component({ 9 | templateUrl: './settings.component.html', 10 | styleUrls: ['./settings.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class SettingsComponent implements OnDestroy { 14 | @ViewChild(MatSidenav) sidenav: MatSidenav; 15 | 16 | readonly mobileQuery: MediaQueryList; 17 | readonly toolbarBreakpointQuery: MediaQueryList; 18 | private readonly _mediaQueryListener: () => void; 19 | 20 | readonly user = this.apiService.user(); 21 | readonly guilds = this.user.guilds.filter(guild => guild.role !== 'user'); 22 | 23 | constructor(private apiService: ApiService, private router: Router, changeDetectorRef: ChangeDetectorRef, media: MediaMatcher) { 24 | this.mobileQuery = media.matchMedia('(max-width: 750px)'); 25 | this.toolbarBreakpointQuery = media.matchMedia('(max-width: 599px)'); 26 | this._mediaQueryListener = () => changeDetectorRef.detectChanges(); 27 | this.mobileQuery.addEventListener('change', this._mediaQueryListener); 28 | this.toolbarBreakpointQuery.addEventListener('change', this._mediaQueryListener); 29 | 30 | this.router.events.pipe(takeUntilDestroyed()).subscribe(() => { 31 | if (this.mobileQuery.matches) { 32 | this.sidenav?.close(); 33 | } 34 | }); 35 | } 36 | 37 | ngOnDestroy() { 38 | this.toolbarBreakpointQuery.removeEventListener('change', this._mediaQueryListener); 39 | this.mobileQuery.removeEventListener('change', this._mediaQueryListener); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-manager.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | position: relative; 3 | flex: 1; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | mat-toolbar { 9 | height: auto; 10 | min-height: 64px; 11 | margin-bottom: 16px; 12 | 13 | flex-wrap: wrap; 14 | column-gap: 16px; 15 | 16 | @media screen and (max-width: 599px) { 17 | min-height: 56px; 18 | } 19 | 20 | .title { 21 | // Padding and margin are used to make italic text not be cut off 22 | padding: 8px 4px; 23 | margin: 0 -4px; 24 | 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | 28 | .guild-name { 29 | font-style: italic; 30 | } 31 | } 32 | 33 | .processing-spinner { 34 | margin: 0 12px; 35 | } 36 | 37 | .controls-wrapper { 38 | flex: 1 1 auto; 39 | display: flex; 40 | justify-content: flex-end; 41 | flex-wrap: wrap; 42 | font-size: 14px; 43 | 44 | .control { 45 | display: flex; 46 | align-items: center; 47 | margin-left: 8px; 48 | } 49 | 50 | input[type='file'] { 51 | display: none; 52 | } 53 | } 54 | 55 | .invisible { 56 | visibility: hidden; 57 | } 58 | } 59 | 60 | .sound-list { 61 | display: block; 62 | margin: 0 8px; 63 | } 64 | 65 | .sound-scroller { 66 | flex: 1; 67 | } 68 | 69 | .message-container { 70 | text-align: center; 71 | } 72 | 73 | // Fix mat-expansion-panel styling with components in between 74 | :host ::ng-deep .sound-list app-sound-details { 75 | &:not(:first-of-type) .mat-expansion-panel:not(.mat-expanded) { 76 | border-top-right-radius: 0 !important; 77 | border-top-left-radius: 0 !important; 78 | } 79 | &:not(:last-of-type) .mat-expansion-panel:not(.mat-expanded) { 80 | border-bottom-right-radius: 0 !important; 81 | border-bottom-left-radius: 0 !important; 82 | } 83 | } 84 | 85 | :host ::ng-deep .cdk-virtual-scroll-content-wrapper { 86 | max-width: 100%; 87 | } 88 | -------------------------------------------------------------------------------- /backend/src/audio_utils.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::path::PathBuf; 3 | use std::process::Stdio; 4 | use tokio::process::Command; 5 | 6 | #[derive(Clone, Debug)] 7 | pub struct VolumeInformation { 8 | pub max_volume: f32, 9 | pub mean_volume: f32, 10 | } 11 | 12 | #[instrument] 13 | pub async fn detect_volume(path: &PathBuf) -> Option { 14 | lazy_static! { 15 | static ref RE_MAX: Regex = Regex::new("max_volume: ([-]?[\\d]+.[\\d]+) dB").unwrap(); 16 | static ref RE_MEAN: Regex = Regex::new("mean_volume: ([-]?[\\d]+.[\\d]+) dB").unwrap(); 17 | } 18 | 19 | let args = ["-af", "volumedetect", "-f", "null", "/dev/null", "-i"]; 20 | 21 | let out = Command::new("ffmpeg") 22 | .kill_on_drop(true) 23 | .args(&args) 24 | .arg(path) 25 | .stdin(Stdio::null()) 26 | .output() 27 | .await 28 | .ok()?; 29 | let parsed = String::from_utf8(out.stderr).ok()?; 30 | 31 | let max_captures = RE_MAX.captures(&parsed)?; 32 | let max_volume = max_captures[1].parse::().ok()?; 33 | let mean_captures = RE_MEAN.captures(&parsed)?; 34 | let mean_volume = mean_captures[1].parse::().ok()?; 35 | debug!(?max_volume, ?mean_volume, "Volume analysis completed"); 36 | 37 | Some(VolumeInformation { 38 | max_volume, 39 | mean_volume, 40 | }) 41 | } 42 | 43 | #[instrument] 44 | pub async fn get_length(path: &PathBuf) -> Option { 45 | let args = [ 46 | "-show_entries", 47 | "format=duration", 48 | "-v", 49 | "quiet", 50 | "-of", 51 | "csv=p=0", 52 | "-i", 53 | ]; 54 | 55 | let out = Command::new("ffprobe") 56 | .kill_on_drop(true) 57 | .args(&args) 58 | .arg(path) 59 | .stdin(Stdio::null()) 60 | .output() 61 | .await; 62 | 63 | let out = out.ok()?; 64 | let parsed = String::from_utf8(out.stdout).ok()?; 65 | debug!(?parsed, "Read sound file length"); 66 | parsed.trim().parse::().ok() 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/app/services/recorder.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | import { Guild } from './api.service'; 6 | 7 | interface ApiRecording { 8 | guildId: string; 9 | timestamp: number; 10 | users: ApiRecordingUser[]; 11 | length: number; 12 | } 13 | 14 | export interface Recording extends ApiRecording { 15 | users: RecordingUser[]; 16 | } 17 | 18 | interface ApiRecordingUser { 19 | username: string; 20 | id: string; 21 | } 22 | 23 | export interface RecordingUser extends ApiRecordingUser { 24 | username: string; 25 | id: string; 26 | url: string; 27 | } 28 | 29 | export interface RecordingMix { 30 | start: number; 31 | end: number; 32 | userIds: string[]; 33 | } 34 | 35 | export interface MixingResult { 36 | downloadUrl: string; 37 | } 38 | 39 | @Injectable({ 40 | providedIn: 'root', 41 | }) 42 | export class RecorderService { 43 | constructor(private http: HttpClient) {} 44 | 45 | record(guild: Guild | string) { 46 | const guildId = typeof guild === 'string' ? guild : guild.id; 47 | return this.http.post(`/api/guilds/${guildId}/record`, {}, { responseType: 'text' }); 48 | } 49 | 50 | loadRecordings(): Observable { 51 | return this.http.get('/api/recordings').pipe( 52 | map(data => 53 | data.map(recording => ({ 54 | ...recording, 55 | users: recording.users.map(user => ({ 56 | ...user, 57 | url: `/api/guilds/${recording.guildId}/recordings/${recording.timestamp}/${user.id}`, 58 | })), 59 | })) 60 | ) 61 | ); 62 | } 63 | 64 | mixRecording(recording: Recording, mix: RecordingMix) { 65 | return this.http.post(`/api/guilds/${recording.guildId}/recordings/${recording.timestamp}`, mix); 66 | } 67 | 68 | deleteRecording(recording: Recording) { 69 | return this.http.delete(`/api/guilds/${recording.guildId}/recordings/${recording.timestamp}`); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /frontend/src/app/header/header.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 📢 {{ pageTitle }} 5 |
6 | 7 |
8 | 9 |
10 | 11 |
12 |
18 | The user's avatar 19 |
20 |
21 | 22 | 23 | settings Settings 24 | keyboard Keybinds 25 | 26 | add Add bot to server 34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 |
42 | 43 | 44 | 45 | speaker 46 | Soundboard 47 | 48 | voicemail Recorder 49 | 50 | -------------------------------------------------------------------------------- /backend/src/main.rs: -------------------------------------------------------------------------------- 1 | // Needed for Diesel Postgres linking for MUSL 2 | // https://github.com/emk/rust-musl-builder#making-diesel-work 3 | extern crate openssl; 4 | #[macro_use] 5 | extern crate diesel; 6 | #[macro_use] 7 | extern crate lazy_static; 8 | #[macro_use] 9 | extern crate rocket; 10 | #[macro_use] 11 | extern crate tracing; 12 | 13 | mod api; 14 | mod audio_utils; 15 | mod db; 16 | mod discord; 17 | mod file_handling; 18 | 19 | use discord::connector::Connector as DiscordConnector; 20 | use discord::CacheHttp; 21 | use dotenv::dotenv; 22 | use std::env; 23 | use tokio::select; 24 | use tracing_subscriber::{fmt, EnvFilter}; 25 | 26 | lazy_static! { 27 | // URL under which the app is reachable 28 | static ref BASE_URL: String = env::var("BASE_URL").expect("BASE_URL must be supplied in env"); 29 | } 30 | 31 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 32 | pub const BUILD_ID: Option<&'static str> = option_env!("BUILD_ID"); 33 | pub const BUILD_TIMESTAMP: Option<&'static str> = option_env!("BUILD_TIMESTAMP"); 34 | 35 | #[rocket::main] 36 | async fn main() { 37 | // Load .env file 38 | dotenv().ok(); 39 | 40 | // Disable serenity logging because it leads to audio problems 41 | let filter = EnvFilter::from_default_env() 42 | .add_directive("serenity=off".parse().unwrap()) 43 | .add_directive("songbird=off".parse().unwrap()); 44 | let format = fmt::format(); 45 | let subscriber = fmt().event_format(format).with_env_filter(filter).finish(); 46 | tracing::subscriber::set_global_default(subscriber).expect("setting tracing default failed"); 47 | 48 | file_handling::create_folders() 49 | .await 50 | .expect("failed to create data-folders"); 51 | 52 | let mut connector = DiscordConnector::new().await; 53 | let cache_http = connector.cache_http.clone(); 54 | let client = connector.client.clone(); 55 | let discord_future = connector.run(); 56 | 57 | let rocket_future = api::run(cache_http, client); 58 | 59 | info!("Startup successful"); 60 | select!(_ = discord_future => info!("Serenity terminated"), _ = rocket_future => info!("Rocket terminated")); 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/app/settings/random-infixes/random-infixes.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | ChangeDetectorRef, 4 | Component, 5 | EventEmitter, 6 | Input, 7 | OnChanges, 8 | Output, 9 | SimpleChanges, 10 | } from '@angular/core'; 11 | import { tap } from 'rxjs/operators'; 12 | import { RandomInfix } from 'src/app/services/api.service'; 13 | import { GuildSettingsService } from '../../services/guild-settings.service'; 14 | 15 | @Component({ 16 | selector: 'app-random-infixes', 17 | templateUrl: './random-infixes.component.html', 18 | styleUrls: ['./random-infixes.component.scss'], 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | }) 21 | export class RandomInfixesComponent implements OnChanges { 22 | @Input({ required: true }) guildId: string; 23 | @Input({ required: true }) randomInfixes: RandomInfix[]; 24 | @Output() hasChanges = new EventEmitter(); 25 | 26 | infixes: RandomInfix[]; 27 | 28 | constructor(private guildSettingsService: GuildSettingsService, private cdRef: ChangeDetectorRef) {} 29 | 30 | addRandomInfix() { 31 | this.infixes.push({ guildId: this.guildId, displayName: '', infix: '' }); 32 | this.infixes = this.infixes.slice(); 33 | this.hasChanges.emit(true); 34 | } 35 | 36 | removeRandomInfix(index: number) { 37 | this.infixes.splice(index, 1); 38 | this.infixes = this.infixes.slice(); 39 | this.hasChanges.emit(true); 40 | } 41 | 42 | ngOnChanges(changes: SimpleChanges) { 43 | if ('randomInfixes' in changes && this.infixes == null) { 44 | this.discardChanges(); 45 | } 46 | } 47 | 48 | discardChanges() { 49 | this.infixes = [...this.randomInfixes]; 50 | this.hasChanges.emit(false); 51 | } 52 | 53 | saveChanges() { 54 | return this.guildSettingsService 55 | .updateRandomInfixes( 56 | this.guildId, 57 | this.infixes.filter(infix => infix.displayName.length > 0 && infix.infix.length > 0) 58 | ) 59 | .pipe( 60 | tap(() => { 61 | this.randomInfixes = this.infixes; 62 | this.discardChanges(); 63 | this.cdRef.markForCheck(); 64 | }) 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/soundboard-button/soundboard-button.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-soundboard-button', 5 | templateUrl: './soundboard-button.component.html', 6 | styleUrls: ['./soundboard-button.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | }) 9 | export class SoundboardButtonComponent implements AfterViewInit { 10 | @Input({ required: true }) guildId: string; 11 | @Input() category?: string; 12 | @Input() isLocallyPlaying = false; 13 | @Input() canPlayLocally = true; 14 | @Output() playRemote = new EventEmitter(); 15 | @Output() playLocal = new EventEmitter(); 16 | @Output() stopLocal = new EventEmitter(); 17 | 18 | @ViewChild('soundName') nameLabel: ElementRef; 19 | 20 | get displayedCategory() { 21 | return this.category == null || this.category === '' ? '' : `/${this.category}`; 22 | } 23 | 24 | ngAfterViewInit() { 25 | this.setLabelMinWidthToFitContent(); 26 | } 27 | 28 | playSound(local = false) { 29 | if (local) { 30 | this.playLocal.emit(); 31 | } else { 32 | this.playRemote.emit(); 33 | } 34 | } 35 | 36 | handleLocalSound() { 37 | if (this.isLocallyPlaying) { 38 | this.stopLocal.emit(); 39 | } else { 40 | this.playSound(true); 41 | } 42 | } 43 | 44 | setLabelMinWidthToFitContent() { 45 | const label = this.nameLabel.nativeElement; 46 | label.style.whiteSpace = 'nowrap'; 47 | const computedStyle = window.getComputedStyle(label); 48 | const horizontalPadding = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight); 49 | const singleLineWidth = label.clientWidth - horizontalPadding; 50 | 51 | // subtract all paddings/margins from viewport width; this gets the maximum width, the label can have 52 | const cssMaxWidth = 'calc(100vw - 72px - 16px - 16px - 10px)'; 53 | 54 | label.style.minWidth = `min(${singleLineWidth / 2 + horizontalPadding + 50}px, ${cssMaxWidth})`; 55 | label.style.whiteSpace = null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-soundboard-bot", 3 | "scripts": { 4 | "ng": "ng", 5 | "start": "ng serve", 6 | "build": "ng build --configuration production", 7 | "test": "ng test", 8 | "lint": "ng lint", 9 | "format": "prettier --write .", 10 | "format:check": "prettier --check .", 11 | "e2e": "ng e2e" 12 | }, 13 | "dependencies": { 14 | "@angular/animations": "^16.2.5", 15 | "@angular/cdk": "^16.2.4", 16 | "@angular/common": "^16.2.5", 17 | "@angular/compiler": "^16.2.5", 18 | "@angular/core": "^16.2.5", 19 | "@angular/forms": "^16.2.5", 20 | "@angular/material": "^16.2.4", 21 | "@angular/platform-browser": "^16.2.5", 22 | "@angular/platform-browser-dynamic": "^16.2.5", 23 | "@angular/router": "^16.2.5", 24 | "@ng-web-apis/audio": "^3.0.2", 25 | "fuse.js": "^6.6.2", 26 | "lodash-es": "^4.17.21", 27 | "ngx-mat-select-search": "^7.0.4", 28 | "ngx-timeago": "^3.0.0", 29 | "ress": "^5.0.2", 30 | "rxjs": "~7.8.1", 31 | "tslib": "^2.1.0", 32 | "zone.js": "~0.13.3" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "^16.2.2", 36 | "@angular-eslint/builder": "16.1.2", 37 | "@angular-eslint/eslint-plugin": "16.1.2", 38 | "@angular-eslint/eslint-plugin-template": "16.1.2", 39 | "@angular-eslint/schematics": "16.1.2", 40 | "@angular-eslint/template-parser": "16.1.2", 41 | "@angular/cli": "^16.2.2", 42 | "@angular/compiler-cli": "^16.2.5", 43 | "@types/jasmine": "^3.9.1", 44 | "@types/jasminewd2": "~2.0.10", 45 | "@types/lodash-es": "^4.17.6", 46 | "@types/node": "^16.10.2", 47 | "@typescript-eslint/eslint-plugin": "^5.59.2", 48 | "@typescript-eslint/parser": "^5.59.2", 49 | "eslint": "^8.39.0", 50 | "eslint-plugin-import": "^2.26.0", 51 | "husky": "^8.0.1", 52 | "jasmine-core": "~3.9.0", 53 | "jasmine-spec-reporter": "~7.0.0", 54 | "karma": "^6.4.0", 55 | "karma-chrome-launcher": "^3.1.1", 56 | "karma-coverage-istanbul-reporter": "~3.0.3", 57 | "karma-jasmine": "~4.0.1", 58 | "karma-jasmine-html-reporter": "^1.7.0", 59 | "prettier": "^2.7.1", 60 | "pretty-quick": "^3.1.3", 61 | "protractor": "~7.0.0", 62 | "ts-node": "~9.1.1", 63 | "typescript": "~4.9.5" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/app/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable, signal } from '@angular/core'; 3 | import { map } from 'rxjs'; 4 | import { sortBy } from 'lodash-es'; 5 | import { tap } from 'rxjs/operators'; 6 | 7 | export interface AppInfo { 8 | version: string; 9 | buildId?: string; 10 | buildTimestamp?: number; 11 | discordClientId: string; 12 | legalUrl?: string; 13 | } 14 | 15 | export type UserRole = 'admin' | 'moderator' | 'user'; 16 | 17 | export interface Guild { 18 | id: string; 19 | name: string; 20 | iconUrl?: string; 21 | role: UserRole; 22 | } 23 | 24 | export interface User { 25 | id: string; 26 | username: string; 27 | discriminator: number; 28 | avatarUrl: string; 29 | guilds: Guild[]; 30 | } 31 | 32 | export interface RandomInfix { 33 | guildId: string; 34 | infix: string; 35 | displayName: string; 36 | } 37 | 38 | export interface AuthToken { 39 | token: string; 40 | createdAt: number; 41 | } 42 | 43 | @Injectable({ 44 | providedIn: 'root', 45 | }) 46 | export class ApiService { 47 | readonly user = signal(null); 48 | readonly appInfo = signal(null); 49 | 50 | constructor(private http: HttpClient) {} 51 | 52 | loadAppInfo() { 53 | return this.http.get('/api/info'); 54 | } 55 | 56 | loadUser() { 57 | return this.http.get('/api/user'); 58 | } 59 | 60 | loadRandomInfixes() { 61 | return this.http 62 | .get('/api/random-infixes') 63 | .pipe(map(infixes => sortBy(infixes, infix => infix.displayName.toLowerCase()))); 64 | } 65 | 66 | joinCurrentChannel(guildId: string) { 67 | return this.http.post(`/api/guilds/${encodeURIComponent(guildId)}/join`, {}, { responseType: 'text' }); 68 | } 69 | 70 | leaveChannel(guildId: string) { 71 | return this.http.post(`/api/guilds/${encodeURIComponent(guildId)}/leave`, {}, { responseType: 'text' }); 72 | } 73 | 74 | logout() { 75 | return this.http.post('/api/auth/logout', {}, { responseType: 'text' }).pipe(tap(() => this.user.set(null))); 76 | } 77 | 78 | generateAuthToken() { 79 | return this.http.post('/api/auth/token', {}); 80 | } 81 | 82 | getAuthToken() { 83 | return this.http.get('/api/auth/token'); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ############################################################ 2 | ### Stage 1: Build 3 | FROM clux/muslrust:stable as builder 4 | WORKDIR /app 5 | 6 | # Install CMAKE for audiopus_sys 7 | RUN apt-get update && \ 8 | apt-get install -y cmake --no-install-recommends && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # Statically link libopus 12 | ARG LIBOPUS_STATIC=1 13 | 14 | ### Dep caching start 15 | COPY backend/Cargo.toml backend/Cargo.lock ./ 16 | RUN mkdir src && echo "fn main() {}" > src/main.rs 17 | 18 | RUN cargo build --release 19 | ### Dep caching end 20 | 21 | # Not declared earlier for caching 22 | ARG BUILD_ID 23 | ARG BUILD_TIMESTAMP 24 | 25 | COPY backend/ . 26 | RUN touch src/main.rs 27 | RUN cargo build --release 28 | 29 | ############################################################ 30 | ### Stage 2: Compose 31 | FROM debian:stable-slim as composer 32 | 33 | # Get ffmpeg 34 | RUN apt-get update && apt-get install -y curl tar xz-utils \ 35 | && apt-get clean \ 36 | && curl -L -# --compressed -A 'https://github.com/dominikks/discord-soundboard-bot' -o linux-x64.tar.xz 'https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz' \ 37 | && tar -x -C /usr/bin --strip-components 1 -f linux-x64.tar.xz --wildcards '*/ffmpeg' '*/ffprobe' \ 38 | && tar -x -f linux-x64.tar.xz --ignore-case --wildcards -O '**/GPLv3.txt' > /usr/bin/ffmpeg.LICENSE 39 | 40 | RUN addgroup --gid 1000 discordbot \ 41 | && adduser -u 1000 --system --gid 1000 discordbot \ 42 | && mkdir -p /app/data/sounds \ 43 | && mkdir -p /app/data/recorder \ 44 | && chown -R discordbot:discordbot /app 45 | 46 | COPY --chown=discordbot:discordbot --from=builder /app/target/x86_64-unknown-linux-musl/release/discord-soundboard-bot /app/Rocket.toml /app/ 47 | ADD --chown=discordbot:discordbot frontend/dist/discord-soundboard-bot /app/static 48 | 49 | ############################################################ 50 | ### Stage 3: Final image 51 | FROM gcr.io/distroless/cc 52 | LABEL maintainer="dominik@kus.software" 53 | 54 | COPY --from=composer /etc/passwd /etc/ 55 | COPY --from=composer /usr/bin/ffmpeg /usr/bin/ffprobe /usr/bin/ 56 | COPY --from=composer --chown=1000:1000 /app /app 57 | 58 | USER discordbot 59 | WORKDIR /app 60 | VOLUME /app/data/sounds 61 | VOLUME /app/data/recorder 62 | 63 | EXPOSE 8000 64 | ENV RUST_LOG=info 65 | CMD ["/app/discord-soundboard-bot"] -------------------------------------------------------------------------------- /backend/src/db/models.rs: -------------------------------------------------------------------------------- 1 | use crate::db::schema::*; 2 | use bigdecimal::BigDecimal; 3 | use std::time::SystemTime; 4 | 5 | #[derive(Queryable, Insertable, Identifiable, Debug, Clone)] 6 | #[diesel(table_name = guildsettings)] 7 | pub struct GuildSettings { 8 | pub id: BigDecimal, 9 | pub user_role_id: Option, 10 | pub moderator_role_id: Option, 11 | pub target_max_volume: f32, 12 | pub target_mean_volume: f32, 13 | } 14 | 15 | #[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug)] 16 | #[diesel(table_name = users)] 17 | pub struct User { 18 | pub id: BigDecimal, 19 | pub last_login: SystemTime, 20 | } 21 | 22 | #[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug)] 23 | #[diesel(table_name = randominfixes)] 24 | #[diesel(primary_key(guild_id, infix))] 25 | pub struct RandomInfix { 26 | pub guild_id: BigDecimal, 27 | pub infix: String, 28 | pub display_name: String, 29 | } 30 | 31 | #[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug, Clone)] 32 | #[diesel(table_name = authtokens)] 33 | #[diesel(primary_key(user_id))] 34 | pub struct AuthToken { 35 | pub user_id: BigDecimal, 36 | pub token: String, 37 | pub creation_time: SystemTime, 38 | } 39 | 40 | #[derive(Queryable, Insertable, Identifiable, Debug, Clone)] 41 | #[diesel(table_name = sounds)] 42 | pub struct Sound { 43 | pub id: i32, 44 | pub guild_id: BigDecimal, 45 | pub name: String, 46 | pub category: String, 47 | pub created_by_user_id: Option, 48 | pub created_at: SystemTime, 49 | pub last_edited_by_user_id: Option, 50 | pub last_edited_at: SystemTime, 51 | pub volume_adjustment: Option, 52 | } 53 | 54 | #[derive(AsChangeset, Debug, Clone)] 55 | #[diesel(table_name = sounds)] 56 | pub struct SoundChangeset { 57 | pub name: Option, 58 | pub category: Option, 59 | pub volume_adjustment: Option>, 60 | } 61 | 62 | #[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug, Clone)] 63 | #[diesel(table_name = soundfiles)] 64 | #[diesel(primary_key(sound_id))] 65 | pub struct Soundfile { 66 | pub sound_id: i32, 67 | pub file_name: String, 68 | pub max_volume: f32, 69 | pub mean_volume: f32, 70 | pub length: f32, 71 | pub uploaded_by_user_id: Option, 72 | pub uploaded_at: SystemTime, 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/soundboard.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | flex: 1; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | $main-width: 1500px; 8 | 9 | .max-width { 10 | margin: 0 auto; 11 | max-width: $main-width; 12 | width: 100%; 13 | } 14 | 15 | mat-toolbar.controls-toolbar { 16 | height: auto; 17 | min-height: 64px; 18 | 19 | position: sticky; 20 | top: 0; 21 | z-index: 1; 22 | 23 | .toolbar-content-wrapper { 24 | @extend .max-width; 25 | display: flex; 26 | align-items: center; 27 | flex-wrap: wrap; 28 | justify-content: flex-end; 29 | font-size: 14px; 30 | gap: 10px; 31 | } 32 | 33 | mat-form-field, 34 | app-volume-slider { 35 | @media (min-width: 900px) { 36 | &:not(.search-field) { 37 | flex: 1 1 150px; 38 | max-width: 250px; 39 | } 40 | 41 | &.search-field { 42 | flex: 1 0 150px; 43 | } 44 | } 45 | 46 | flex: 49%; 47 | } 48 | } 49 | 50 | mat-toolbar.discord-toolbar { 51 | height: auto; 52 | min-height: 48px; 53 | 54 | font-size: 14px; 55 | 56 | .event-text { 57 | color: lightgray; 58 | text-overflow: ellipsis; 59 | overflow: hidden; 60 | } 61 | 62 | .toolbar-content-wrapper { 63 | @extend .max-width; 64 | 65 | display: flex; 66 | align-items: center; 67 | gap: 8px; 68 | } 69 | 70 | .filler { 71 | flex: 1; 72 | } 73 | 74 | .stop-button { 75 | flex-shrink: 0; 76 | 77 | ::ng-deep .mdc-button__label { 78 | z-index: unset; 79 | } 80 | } 81 | } 82 | 83 | mat-toolbar:last-of-type { 84 | margin-bottom: 16px; 85 | } 86 | 87 | main { 88 | flex: 1; 89 | padding: 0 5px; 90 | 91 | .button-container { 92 | display: flex; 93 | flex-wrap: wrap; 94 | justify-content: center; 95 | 96 | app-soundboard-button { 97 | margin: 5px; 98 | flex: 1 0 170px; 99 | min-height: 70px; 100 | } 101 | } 102 | 103 | &.centering-wrapper { 104 | display: flex; 105 | justify-content: center; 106 | align-items: center; 107 | } 108 | 109 | &.guild-warning-wrapper { 110 | padding: 16px; 111 | 112 | mat-icon { 113 | margin-right: 8px; 114 | } 115 | } 116 | } 117 | 118 | .invisible { 119 | visibility: hidden; 120 | user-select: none; 121 | } 122 | -------------------------------------------------------------------------------- /frontend/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | /*************************************************************************************************** 51 | * APPLICATION IMPORTS 52 | */ 53 | -------------------------------------------------------------------------------- /frontend/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { guildPermissionGuard } from './guards/guild-permission.guard'; 4 | import { KeybindGeneratorComponent } from './keybind-generator/keybind-generator.component'; 5 | import { RecorderComponent } from './recorder/recorder.component'; 6 | import { GuildSettingsComponent } from './settings/guild-settings/guild-settings.component'; 7 | import { SettingsComponent } from './settings/settings.component'; 8 | import { canDeactivateSoundManagerGuard } from './settings/sound-manager/can-deactivate-sound-manager.guard'; 9 | import { SoundManagerComponent } from './settings/sound-manager/sound-manager.component'; 10 | import { UserSettingsComponent } from './settings/user-settings/user-settings.component'; 11 | import { SoundboardComponent } from './soundboard/soundboard.component'; 12 | import { canDeactivateGuildSettingsGuard } from './settings/guild-settings/can-deactivate-guild-settings.guard'; 13 | 14 | const routes: Routes = [ 15 | { 16 | path: '', 17 | component: SoundboardComponent, 18 | }, 19 | { 20 | path: 'keybind-generator', 21 | component: KeybindGeneratorComponent, 22 | }, 23 | { 24 | path: 'recorder', 25 | component: RecorderComponent, 26 | }, 27 | { 28 | path: 'settings', 29 | component: SettingsComponent, 30 | children: [ 31 | { 32 | path: '', 33 | pathMatch: 'full', 34 | redirectTo: 'user', 35 | }, 36 | { 37 | path: 'user', 38 | component: UserSettingsComponent, 39 | }, 40 | { 41 | path: 'guilds/:guildId', 42 | canActivate: [guildPermissionGuard], 43 | children: [ 44 | { 45 | path: '', 46 | pathMatch: 'full', 47 | redirectTo: 'settings', 48 | }, 49 | { 50 | path: 'settings', 51 | component: GuildSettingsComponent, 52 | canDeactivate: [canDeactivateGuildSettingsGuard], 53 | }, 54 | { 55 | path: 'sounds', 56 | component: SoundManagerComponent, 57 | canDeactivate: [canDeactivateSoundManagerGuard], 58 | }, 59 | ], 60 | }, 61 | ], 62 | }, 63 | { 64 | path: '**', 65 | redirectTo: '', 66 | }, 67 | ]; 68 | 69 | @NgModule({ 70 | imports: [RouterModule.forRoot(routes, { useHash: false, bindToComponentInputs: true })], 71 | exports: [RouterModule], 72 | }) 73 | export class AppRoutingModule {} 74 | -------------------------------------------------------------------------------- /backend/src/discord/connector.rs: -------------------------------------------------------------------------------- 1 | use crate::discord::client::Client; 2 | use crate::discord::client::ClientInit; 3 | use crate::discord::commands; 4 | use crate::CacheHttp; 5 | use serenity::async_trait; 6 | use serenity::client::Client as SerenityClient; 7 | use serenity::client::Context; 8 | use serenity::client::EventHandler; 9 | use serenity::http::Http; 10 | use serenity::model::gateway::GatewayIntents; 11 | use serenity::model::gateway::Ready; 12 | use serenity::model::id::GuildId; 13 | use std::env; 14 | 15 | struct Handler; 16 | 17 | #[async_trait] 18 | impl EventHandler for Handler { 19 | #[instrument(skip(self, _ctx, ready))] 20 | async fn ready(&self, _ctx: Context, ready: Ready) { 21 | info!("{} is connected!", ready.user.name); 22 | } 23 | 24 | #[instrument(skip(self, _ctx))] 25 | async fn cache_ready(&self, _ctx: Context, _guilds: Vec) { 26 | debug!("Cache is ready"); 27 | } 28 | } 29 | 30 | pub struct Connector { 31 | pub cache_http: CacheHttp, 32 | pub client: Client, 33 | serenity_client: SerenityClient, 34 | } 35 | 36 | impl Connector { 37 | #[instrument] 38 | pub async fn new() -> Self { 39 | let token = env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN in env"); 40 | 41 | // Get the Bot ID 42 | let http = Http::new(&token); 43 | let bot_id = http 44 | .get_current_user() 45 | .await 46 | .map(|user| user.id) 47 | .expect("Failed to access bot id"); 48 | 49 | let framework = commands::create_framework(bot_id); 50 | let client = Client::new(); 51 | 52 | // Those intents also update the Serenity cache 53 | let intents = GatewayIntents::GUILDS 54 | | GatewayIntents::GUILD_MEMBERS 55 | | GatewayIntents::GUILD_VOICE_STATES 56 | | GatewayIntents::GUILD_MESSAGES; 57 | 58 | let serenity_client = SerenityClient::builder(&token, intents) 59 | .event_handler(Handler) 60 | .framework(framework) 61 | .register_client(&client) 62 | .await 63 | .expect("Error creating client"); 64 | 65 | Self { 66 | cache_http: CacheHttp::from(&serenity_client.cache_and_http), 67 | serenity_client, 68 | client, 69 | } 70 | } 71 | 72 | #[instrument(skip(self))] 73 | pub async fn run(&mut self) { 74 | if let Err(why) = self.serenity_client.start().await { 75 | error!("Discord client ended: {:?}", why); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /backend/src/api/utils.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::ContentType; 2 | use rocket::response::Responder; 3 | use serenity::model::guild::Member as SerenityMember; 4 | use serenity::model::user::User as SerenityUser; 5 | use std::io; 6 | use std::path::Path; 7 | use std::path::PathBuf; 8 | use tokio::fs::File; 9 | 10 | #[derive(Debug)] 11 | pub struct CachedFile(PathBuf, File); 12 | 13 | impl CachedFile { 14 | pub async fn open>(path: P) -> io::Result { 15 | let file = File::open(path.as_ref()).await?; 16 | Ok(Self(path.as_ref().to_path_buf(), file)) 17 | } 18 | } 19 | 20 | impl<'r> Responder<'r, 'static> for CachedFile { 21 | fn respond_to(self, req: &'r rocket::Request<'_>) -> rocket::response::Result<'static> { 22 | let mut response = self.1.respond_to(req)?; 23 | 24 | // Add file 25 | let content_type = self 26 | .0 27 | .extension() 28 | .and_then(|ext| ContentType::from_extension(&ext.to_string_lossy())); 29 | if let Some(ct) = content_type.clone() { 30 | response.set_header(ct); 31 | } 32 | 33 | let cache_string; 34 | if content_type == Some(ContentType::HTML) { 35 | cache_string = "no-cache"; 36 | } else if content_type == Some(ContentType::CSS) 37 | || content_type == Some(ContentType::JavaScript) 38 | { 39 | cache_string = "public, max-age=315360000"; // indefinitely 40 | } else { 41 | cache_string = "public, max-age=2592000"; // 30d 42 | } 43 | response.set_raw_header("Cache-Control", cache_string); 44 | 45 | Ok(response) 46 | } 47 | } 48 | 49 | pub trait AvatarOrDefault { 50 | /// Get user avatar or default icon, if no avatar is present 51 | fn avatar_url_or_default(&self) -> String; 52 | } 53 | 54 | impl AvatarOrDefault for SerenityUser { 55 | fn avatar_url_or_default(&self) -> String { 56 | self.avatar 57 | .as_ref() 58 | .map(|avatar_hash| { 59 | format!( 60 | "https://cdn.discordapp.com/avatars/{}/{}.png", 61 | self.id, avatar_hash 62 | ) 63 | }) 64 | .unwrap_or(format!( 65 | "https://cdn.discordapp.com/embed/avatars/{}.png", 66 | self.discriminator % 5 67 | )) 68 | } 69 | } 70 | 71 | impl AvatarOrDefault for SerenityMember { 72 | fn avatar_url_or_default(&self) -> String { 73 | self.avatar 74 | .clone() 75 | .unwrap_or_else(|| self.user.avatar_url_or_default()) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build app 2 | 3 | on: push 4 | 5 | jobs: 6 | compile-frontend: 7 | name: Compile frontend 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | working-directory: frontend 20 | 21 | - name: Build 22 | run: npm run build 23 | working-directory: frontend 24 | 25 | - name: Archive binary 26 | uses: actions/upload-artifact@v3 27 | with: 28 | name: frontend 29 | path: frontend/dist/discord-soundboard-bot/** 30 | 31 | docker: 32 | name: Build docker image 33 | needs: [compile-frontend] 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v3 37 | 38 | - name: Download frontend 39 | uses: actions/download-artifact@v3 40 | with: 41 | name: frontend 42 | path: frontend/dist/discord-soundboard-bot/ 43 | 44 | - name: Docker meta 45 | id: docker_meta 46 | uses: docker/metadata-action@v4 47 | with: 48 | images: ghcr.io/dominikks/discord-soundboard-bot 49 | 50 | - name: Set environment variables 51 | if: startsWith(github.ref, 'refs/heads/') 52 | run: | 53 | echo "BUILD_TIMESTAMP=$(git show -s --format=%ct $GITHUB_SHA)" >> $GITHUB_ENV 54 | echo "BUILD_ID=${GITHUB_REF#refs/heads/}#$(git rev-parse --short $GITHUB_SHA)" >> $GITHUB_ENV 55 | 56 | - name: Set up QEMU 57 | uses: docker/setup-qemu-action@v2 58 | 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v2 61 | 62 | - name: Cache Docker layers 63 | uses: actions/cache@v3 64 | with: 65 | path: /tmp/.buildx-cache 66 | key: ${{ runner.os }}-buildx-${{ github.sha }} 67 | restore-keys: | 68 | ${{ runner.os }}-buildx- 69 | 70 | - name: Login to GitHub Container Registry 71 | uses: docker/login-action@v2 72 | with: 73 | registry: ghcr.io 74 | username: ${{ github.actor }} 75 | password: ${{ secrets.GITHUB_TOKEN }} 76 | 77 | - name: Build and push 78 | uses: docker/build-push-action@v3 79 | with: 80 | context: . 81 | push: true 82 | tags: ${{ steps.docker_meta.outputs.tags }} 83 | labels: ${{ steps.docker_meta.outputs.labels }} 84 | cache-from: type=local,src=/tmp/.buildx-cache 85 | cache-to: type=local,dest=/tmp/.buildx-cache,mode=max 86 | build-args: | 87 | BUILD_TIMESTAMP 88 | BUILD_ID 89 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-manager.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Sounds on 5 | {{ _guildId | guildName }}
7 | 8 | 9 |
10 |
11 | 12 | Filter by name 13 | 14 | 17 | 18 |
19 | 20 | 21 |
22 | 25 |
26 |
27 |
28 | 29 | 30 | 31 | 40 | 41 | 42 | 43 | 44 |
45 | There are no sounds matching your filter. 46 | There are no sounds on this server. You can add some by pressing the add button above. 49 |
50 |
51 | 52 | 59 |
60 | -------------------------------------------------------------------------------- /frontend/src/app/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 27 | 28 |
29 | 30 | -------------------------------------------------------------------------------- /backend/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::api::auth::UserId; 2 | use crate::api::events::EventBus; 3 | use crate::db; 4 | use crate::discord::client::Client; 5 | use crate::CacheHttp; 6 | use crate::BUILD_ID; 7 | use crate::BUILD_TIMESTAMP; 8 | use crate::VERSION; 9 | use rocket::error::Error as RocketError; 10 | use rocket::fairing::AdHoc; 11 | use rocket::serde::json::Json; 12 | use rocket::Ignite; 13 | use rocket::Rocket; 14 | use serde::Deserialize; 15 | use serde::Serialize; 16 | use serde_with::serde_as; 17 | use serde_with::skip_serializing_none; 18 | use serde_with::DisplayFromStr; 19 | use std::env::var; 20 | use std::path::Path; 21 | use std::path::PathBuf; 22 | use utils::CachedFile; 23 | 24 | mod auth; 25 | mod commands; 26 | mod events; 27 | mod recorder; 28 | mod settings; 29 | mod sounds; 30 | mod utils; 31 | 32 | /// 64 bit integers can not be accurately represented in javascript. They are therefore 33 | /// treated as strings. This is similar to the Twitter Snowflake type. 34 | #[serde_as] 35 | #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)] 36 | struct Snowflake(#[serde_as(as = "DisplayFromStr")] pub u64); 37 | 38 | lazy_static! { 39 | // Settings for frontend 40 | static ref LEGAL_URL: Option = var("LEGAL_URL").ok(); 41 | // Discord data found in env 42 | static ref DISCORD_CLIENT_ID: String = var("DISCORD_CLIENT_ID").expect("Expected DISCORD_CLIENT_ID as env"); 43 | static ref DISCORD_CLIENT_SECRET: String = var("DISCORD_CLIENT_SECRET").expect("Expected DISCORD_CLIENT_SECRET as env"); 44 | } 45 | 46 | pub async fn run(cache_http: CacheHttp, client: Client) -> Result, RocketError> { 47 | rocket::build() 48 | .attach(db::DbConn::fairing()) 49 | .attach(AdHoc::on_ignite( 50 | "Database migrations", 51 | db::run_db_migrations, 52 | )) 53 | .mount("/", routes![frontend, info]) 54 | .mount("/api", auth::get_routes()) 55 | .mount("/api/guilds", commands::get_routes()) 56 | .mount("/api/sounds", sounds::get_routes()) 57 | .mount("/api", recorder::get_routes()) 58 | .mount("/api", settings::get_routes()) 59 | .mount("/api", events::get_routes()) 60 | .manage(cache_http) 61 | .manage(client) 62 | .manage(auth::get_oauth_client()) 63 | // Channel for server sent events 64 | .manage(EventBus::new()) 65 | .launch() 66 | .await 67 | } 68 | 69 | #[get("/", rank = 100)] 70 | async fn frontend(path: PathBuf) -> Option { 71 | let mut file = Path::new("static").join(path); 72 | if !file.is_file() { 73 | file = Path::new("static").join("index.html"); 74 | } 75 | CachedFile::open(file).await.ok() 76 | } 77 | 78 | #[skip_serializing_none] 79 | #[derive(Debug, Serialize)] 80 | #[serde(rename_all = "camelCase")] 81 | struct InfoResponse { 82 | version: String, 83 | build_id: Option, 84 | build_timestamp: Option, 85 | discord_client_id: String, 86 | legal_url: Option, 87 | } 88 | 89 | #[get("/api/info")] 90 | async fn info() -> Json { 91 | Json(InfoResponse { 92 | version: VERSION.to_string(), 93 | build_id: BUILD_ID.map(|s| s.to_string()), 94 | build_timestamp: BUILD_TIMESTAMP.and_then(|s| s.parse::().ok()), 95 | discord_client_id: DISCORD_CLIENT_ID.clone(), 96 | legal_url: LEGAL_URL.clone(), 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /frontend/src/app/services/sounds.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { map } from 'rxjs/operators'; 4 | import { sortBy } from 'lodash-es'; 5 | import { Guild } from './api.service'; 6 | 7 | interface ApiSound { 8 | id: string; 9 | guildId: string; 10 | name: string; 11 | category: string; 12 | createdAt: number; 13 | volumeAdjustment?: number; 14 | soundFile?: Readonly; 15 | } 16 | 17 | export interface SoundFile { 18 | maxVolume: number; 19 | meanVolume: number; 20 | length: number; 21 | uploadedAt: number; 22 | } 23 | 24 | export class Sound implements ApiSound { 25 | id: string; 26 | guildId: string; 27 | name: string; 28 | category: string; 29 | createdAt: number; 30 | volumeAdjustment?: number; 31 | soundFile?: Readonly; 32 | 33 | constructor(base: ApiSound) { 34 | this.id = base.id; 35 | this.guildId = base.guildId; 36 | this.name = base.name; 37 | this.category = base.category; 38 | this.createdAt = base.createdAt; 39 | this.volumeAdjustment = base.volumeAdjustment; 40 | this.soundFile = base.soundFile; 41 | } 42 | 43 | getDownloadUrl() { 44 | return `/api/sounds/${this.encodeId()}`; 45 | } 46 | 47 | getPlayUrl(guild: Guild | string) { 48 | // We can play a sound on a different guild than where it is located 49 | const guildid = typeof guild === 'string' ? guild : guild.id; 50 | return `/api/guilds/${guildid}/play/${this.encodeId()}`; 51 | } 52 | 53 | encodeId() { 54 | return this.id 55 | .split('/') 56 | .map(part => encodeURIComponent(part)) 57 | .join('/'); 58 | } 59 | } 60 | 61 | @Injectable({ 62 | providedIn: 'root', 63 | }) 64 | export class SoundsService { 65 | constructor(private http: HttpClient) {} 66 | 67 | loadSounds() { 68 | return this.http.get('/api/sounds').pipe( 69 | map(sounds => sounds.map(sound => new Sound(sound))), 70 | map(sounds => sortBy(sounds, sound => sound.name.toLowerCase())) 71 | ); 72 | } 73 | 74 | playSound(sound: Sound, guild: Guild | string, autojoin: boolean) { 75 | return this.http.post(sound.getPlayUrl(guild), {}, { params: { autojoin } }); 76 | } 77 | 78 | stopSound(guild: Guild | string) { 79 | const guildId = typeof guild === 'string' ? guild : guild.id; 80 | return this.http.post(`/api/guilds/${guildId}/stop`, {}, { responseType: 'text' }); 81 | } 82 | 83 | createSound(guildId: string, name: string, category: string) { 84 | return this.http.post(`/api/sounds`, { guildId, name, category }).pipe(map(sound => new Sound(sound))); 85 | } 86 | 87 | updateSound(sound: Sound) { 88 | return this.http.put( 89 | `/api/sounds/${encodeURIComponent(sound.id)}`, 90 | { name: sound.name, category: sound.category, volumeAdjustment: sound.volumeAdjustment }, 91 | { responseType: 'text' } 92 | ); 93 | } 94 | 95 | deleteSound(sound: Sound) { 96 | return this.http.delete(`/api/sounds/${encodeURIComponent(sound.id)}`, { responseType: 'text' }); 97 | } 98 | 99 | uploadSound(sound: Sound, file: File) { 100 | return this.http.post(`/api/sounds/${encodeURIComponent(sound.id)}`, file, { 101 | headers: { 102 | 'Content-Type': file.type, 103 | }, 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /frontend/src/app/settings/settings.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 | The user's Discord avatar 15 |
User settings
16 |
17 | 18 | 19 | 20 | The Discord server's avatar 21 |
{{ guild.name }}
22 |
{{ guild.role === 'admin' ? 'Admin' : 'Moderator' }}
23 | 24 | 25 | 26 | 31 | 35 | 39 | 40 | 41 |
42 |
Settings
45 |
Sounds
48 |
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["tsconfig.json", "e2e/tsconfig.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": [ 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:@angular-eslint/recommended", 14 | "plugin:@angular-eslint/template/process-inline-templates" 15 | ], 16 | "rules": { 17 | "@angular-eslint/component-selector": [ 18 | "error", 19 | { 20 | "prefix": "app", 21 | "style": "kebab-case", 22 | "type": "element" 23 | } 24 | ], 25 | "@angular-eslint/directive-selector": [ 26 | "error", 27 | { 28 | "prefix": "app", 29 | "style": "camelCase", 30 | "type": "attribute" 31 | } 32 | ], 33 | "@angular-eslint/use-lifecycle-interface": "error", 34 | "@typescript-eslint/explicit-member-accessibility": [ 35 | "off", 36 | { 37 | "accessibility": "explicit" 38 | } 39 | ], 40 | "@typescript-eslint/member-ordering": [ 41 | "error", 42 | { 43 | "default": ["static-field", "static-initialization", "static-method", "signature", "field", "constructor", "method"] 44 | } 45 | ], 46 | "@typescript-eslint/no-inferrable-types": [ 47 | "error", 48 | { 49 | "ignoreParameters": true 50 | } 51 | ], 52 | "@typescript-eslint/no-non-null-assertion": "error", 53 | "@typescript-eslint/type-annotation-spacing": "error", 54 | "@typescript-eslint/no-explicit-any": "off", 55 | "@typescript-eslint/no-unused-vars": [ 56 | "error", 57 | { 58 | "argsIgnorePattern": "^_" 59 | } 60 | ], 61 | "id-blacklist": "off", 62 | "id-match": "off", 63 | "import/no-deprecated": "warn", 64 | "import/order": "error", 65 | "max-classes-per-file": "off", 66 | "max-len": [ 67 | "error", 68 | { 69 | "code": 140 70 | } 71 | ], 72 | "no-console": [ 73 | "error", 74 | { 75 | "allow": [ 76 | "log", 77 | "warn", 78 | "dir", 79 | "timeLog", 80 | "assert", 81 | "clear", 82 | "count", 83 | "countReset", 84 | "group", 85 | "groupEnd", 86 | "table", 87 | "dirxml", 88 | "error", 89 | "groupCollapsed", 90 | "Console", 91 | "profile", 92 | "profileEnd", 93 | "timeStamp", 94 | "context" 95 | ] 96 | } 97 | ], 98 | "no-empty": [ 99 | "error", 100 | { 101 | "allowEmptyCatch": true 102 | } 103 | ], 104 | "no-fallthrough": "error", 105 | "no-restricted-imports": ["error", "rxjs/Rx"], 106 | "no-underscore-dangle": "off" 107 | }, 108 | "plugins": ["eslint-plugin-import"] 109 | }, 110 | { 111 | "files": ["*.html"], 112 | "extends": ["plugin:@angular-eslint/template/recommended"], 113 | "rules": { 114 | "@angular-eslint/template/eqeqeq": [ 115 | "error", 116 | { 117 | "allowNullOrUndefined": true 118 | } 119 | ] 120 | } 121 | } 122 | ] 123 | } 124 | -------------------------------------------------------------------------------- /frontend/src/app/data-load/data-load.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationRef, 3 | Directive, 4 | Input, 5 | isSignal, 6 | OnChanges, 7 | Renderer2, 8 | SimpleChanges, 9 | TemplateRef, 10 | ViewContainerRef, 11 | WritableSignal, 12 | } from '@angular/core'; 13 | import { MatProgressSpinner } from '@angular/material/progress-spinner'; 14 | import { Observable, Subject, Subscription } from 'rxjs'; 15 | import { DataLoadErrorComponent } from './data-load-error.component'; 16 | 17 | interface DataLoadContext { 18 | $implicit: T; 19 | appDataLoad: T; 20 | } 21 | 22 | /** 23 | * We borrow some code from the ngIf directive to enable type checking in the template: 24 | * https://angular.io/guide/structural-directives#improving-template-type-checking-for-custom-directives 25 | * https://github.com/angular/angular/blob/e40a640dfe54b03bfe917d08098c319b0b200d25/packages/common/src/directives/ng_if.ts#L230 26 | */ 27 | @Directive({ 28 | selector: '[appDataLoad]', 29 | }) 30 | export class DataLoadDirective implements OnChanges { 31 | // eslint-disable-next-line @typescript-eslint/naming-convention 32 | static ngTemplateGuard_appDataLoad: 'binding'; 33 | 34 | static ngTemplateContextGuard( 35 | _dir: DataLoadDirective, 36 | ctx: unknown 37 | ): ctx is DataLoadContext> { 38 | return true; 39 | } 40 | 41 | @Input({ required: true }) appDataLoad: Observable; 42 | @Input() appDataLoadCallback?: WritableSignal | Subject; 43 | 44 | private state: 'loading' | 'error' | 'done'; 45 | private data: T; 46 | private activeSubscription: Subscription; 47 | 48 | constructor( 49 | private templateRef: TemplateRef>, 50 | private appRef: ApplicationRef, 51 | private viewContainer: ViewContainerRef, 52 | private renderer: Renderer2 53 | ) {} 54 | 55 | ngOnChanges(changes: SimpleChanges) { 56 | if ('appDataLoad' in changes) { 57 | this.resubscribe(); 58 | } 59 | } 60 | 61 | private resubscribe() { 62 | this.activeSubscription?.unsubscribe(); 63 | this.state = 'loading'; 64 | this.update(); 65 | 66 | this.activeSubscription = this.appDataLoad.subscribe({ 67 | next: data => { 68 | this.state = 'done'; 69 | this.data = data; 70 | 71 | if (isSignal(this.appDataLoadCallback)) { 72 | this.appDataLoadCallback.set(data); 73 | } else if (this.appDataLoadCallback instanceof Subject) { 74 | this.appDataLoadCallback.next(data); 75 | } 76 | 77 | this.update(); 78 | }, 79 | error: () => { 80 | this.state = 'error'; 81 | this.update(); 82 | }, 83 | }); 84 | } 85 | 86 | private update() { 87 | this.viewContainer.clear(); 88 | 89 | switch (this.state) { 90 | case 'done': 91 | const view = this.viewContainer.createEmbeddedView(this.templateRef, { 92 | $implicit: this.data, 93 | appDataLoad: this.data, 94 | } satisfies DataLoadContext); 95 | view.detectChanges(); 96 | break; 97 | case 'loading': { 98 | const loadingSpinner = this.viewContainer.createComponent(MatProgressSpinner); 99 | loadingSpinner.instance.mode = 'indeterminate'; 100 | this.renderer.setStyle(loadingSpinner.location.nativeElement, 'margin', '16px auto'); 101 | loadingSpinner.changeDetectorRef.detectChanges(); 102 | break; 103 | } 104 | case 'error': { 105 | const componentRef = this.viewContainer.createComponent(DataLoadErrorComponent); 106 | const retrySubscription = componentRef.instance.retry.subscribe(() => this.resubscribe()); 107 | componentRef.onDestroy(() => retrySubscription.unsubscribe()); 108 | componentRef.changeDetectorRef.detectChanges(); 109 | break; 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /backend/src/file_handling.rs: -------------------------------------------------------------------------------- 1 | use crate::audio_utils; 2 | use std::ffi::OsString; 3 | use std::fmt; 4 | use std::path::Path; 5 | use std::path::PathBuf; 6 | use std::time::Duration; 7 | use std::time::SystemTime; 8 | use tokio::fs; 9 | use tokio::fs::ReadDir; 10 | use tokio::io; 11 | 12 | lazy_static! { 13 | static ref SOUNDS_FOLDER: &'static Path = Path::new("data/sounds"); 14 | pub static ref RECORDINGS_FOLDER: &'static Path = Path::new("data/recorder"); 15 | pub static ref MIXES_FOLDER: &'static Path = Path::new("data/mixes"); 16 | } 17 | 18 | pub async fn create_folders() -> Result<(), io::Error> { 19 | fs::create_dir_all(*SOUNDS_FOLDER).await?; 20 | fs::create_dir_all(*RECORDINGS_FOLDER).await?; 21 | if MIXES_FOLDER.exists() { 22 | // Clean temporary mixes that might be remaining 23 | fs::remove_dir_all(*MIXES_FOLDER).await?; 24 | } 25 | fs::create_dir_all(*MIXES_FOLDER).await?; 26 | 27 | Ok(()) 28 | } 29 | 30 | pub fn get_full_sound_path(filename: &str) -> PathBuf { 31 | (*SOUNDS_FOLDER).join(filename) 32 | } 33 | 34 | #[derive(Debug)] 35 | pub enum FileError { 36 | IoError(io::Error), 37 | } 38 | 39 | impl From for FileError { 40 | fn from(err: io::Error) -> Self { 41 | FileError::IoError(err) 42 | } 43 | } 44 | 45 | impl fmt::Display for FileError { 46 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { 47 | match self { 48 | FileError::IoError(err) => write!(f, "FileError: IoError occurred. {}", err), 49 | } 50 | } 51 | } 52 | 53 | #[derive(Debug)] 54 | pub struct Recording { 55 | pub guild_id: u64, 56 | pub timestamp: SystemTime, 57 | pub length: f32, 58 | pub users: Vec, 59 | } 60 | 61 | #[derive(Debug)] 62 | pub struct RecordingUser { 63 | /// Parsed name. This is not equivalent to the file name. 64 | pub name: String, 65 | /// This is the file name in the recordings folder 66 | pub file_name: OsString, 67 | } 68 | 69 | #[instrument(err)] 70 | pub async fn get_recordings_for_guild(guild_id: u64) -> Result, FileError> { 71 | let mut results = Vec::new(); 72 | let start_dir = (*RECORDINGS_FOLDER).join(guild_id.to_string()); 73 | if !start_dir.exists() { 74 | return Ok(vec![]); 75 | } 76 | 77 | let mut dir = (fs::read_dir(start_dir).await as Result)?; 78 | while let Some(file) = dir.next_entry().await? { 79 | let filename = file.file_name(); 80 | let filename = filename.to_string_lossy(); 81 | let metadata = file.metadata().await?; 82 | 83 | if metadata.is_dir() { 84 | if let Ok(timestamp) = filename.parse::() { 85 | let mut rec_dir = (fs::read_dir(file.path()).await as Result)?; 86 | 87 | let mut users = Vec::new(); 88 | let mut length: f32 = 0.0; 89 | while let Some(rec_file) = rec_dir.next_entry().await? { 90 | if let Some(file_stem) = 91 | rec_file.path().file_stem().and_then(|stem| stem.to_str()) 92 | { 93 | users.push(RecordingUser { 94 | name: file_stem.to_string(), 95 | file_name: rec_file.file_name(), 96 | }); 97 | length = length.max( 98 | audio_utils::get_length(&rec_file.path()) 99 | .await 100 | .unwrap_or(0.0), 101 | ); 102 | } 103 | } 104 | 105 | results.push(Recording { 106 | guild_id, 107 | timestamp: SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp), 108 | length, 109 | users, 110 | }); 111 | } else { 112 | warn!(?file, "Directory has invalid name. Must be a number."); 113 | } 114 | } else { 115 | warn!(?file, "File found in invalid location"); 116 | } 117 | } 118 | 119 | Ok(results) 120 | } 121 | -------------------------------------------------------------------------------- /frontend/src/app/settings/guild-settings/guild-settings.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, Input, signal, ViewChild } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | import { finalize } from 'rxjs/operators'; 4 | import { forkJoin } from 'rxjs'; 5 | import { ApiService, RandomInfix } from '../../services/api.service'; 6 | import { RandomInfixesComponent } from '../random-infixes/random-infixes.component'; 7 | import { GuildSettings, GuildSettingsService } from '../../services/guild-settings.service'; 8 | 9 | type SavingState = 'saved' | 'saving' | 'error'; 10 | 11 | @Component({ 12 | templateUrl: './guild-settings.component.html', 13 | styleUrls: ['./guild-settings.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class GuildSettingsComponent { 17 | @ViewChild(RandomInfixesComponent) randomInfixesComponent: RandomInfixesComponent; 18 | 19 | private readonly _guildId = signal(null); 20 | @Input({ required: true }) set guildId(value: string) { 21 | this._guildId.set(value); 22 | } 23 | 24 | readonly guild = computed(() => { 25 | return this.apiService.user().guilds.find(guild => guild.id === this._guildId()); 26 | }); 27 | readonly role = computed(() => { 28 | return this.guild()?.role; 29 | }); 30 | 31 | readonly data$ = computed(() => { 32 | return forkJoin([this.guildSettingsService.loadGuildSettings(this._guildId()), this.apiService.loadRandomInfixes()]); 33 | }); 34 | readonly loadedData = signal<[GuildSettings, RandomInfix[]]>(null); 35 | 36 | readonly guildSettings = computed(() => this.loadedData()[0]); 37 | readonly randomInfixes = computed(() => this.loadedData()[1]); 38 | 39 | readonly filteredRandomInfixes = computed(() => { 40 | const randomInfixes = this.randomInfixes(); 41 | return randomInfixes.filter(infix => infix.guildId === this._guildId()); 42 | }); 43 | 44 | readonly userIsSaving = signal(null); 45 | readonly moderatorIsSaving = signal(null); 46 | readonly meanVolumeIsSaving = signal(null); 47 | readonly maxVolumeIsSaving = signal(null); 48 | 49 | readonly randomInfixesHasChanges = signal(false); 50 | readonly randomInfixIsSaving = signal(false); 51 | 52 | constructor(private apiService: ApiService, private guildSettingsService: GuildSettingsService, private snackBar: MatSnackBar) {} 53 | 54 | saveRandomInfixes() { 55 | this.randomInfixIsSaving.set(true); 56 | this.randomInfixesComponent 57 | .saveChanges() 58 | .pipe(finalize(() => this.randomInfixIsSaving.set(false))) 59 | .subscribe({ 60 | error: err => { 61 | console.error(err); 62 | this.snackBar.open('Failed to save random buttons.', 'Damn', { duration: undefined }); 63 | }, 64 | }); 65 | } 66 | 67 | setUserRoleId(roleId: string, guildId: string) { 68 | this.userIsSaving.set('saving'); 69 | this.guildSettingsService.updateGuildSettings(guildId, { userRoleId: roleId }).subscribe( 70 | () => this.userIsSaving.set('saved'), 71 | () => this.userIsSaving.set('error') 72 | ); 73 | } 74 | 75 | setModeratorRoleId(roleId: string, guildId: string) { 76 | this.moderatorIsSaving.set('saving'); 77 | this.guildSettingsService.updateGuildSettings(guildId, { moderatorRoleId: roleId }).subscribe( 78 | () => this.moderatorIsSaving.set('saved'), 79 | () => this.moderatorIsSaving.set('error') 80 | ); 81 | } 82 | 83 | setMeanVolume(volume: string, guildId: string) { 84 | if (volume.length > 0 && +volume > -30 && +volume < 30) { 85 | this.meanVolumeIsSaving.set('saving'); 86 | this.guildSettingsService.updateGuildSettings(guildId, { targetMeanVolume: +volume }).subscribe( 87 | () => this.meanVolumeIsSaving.set('saved'), 88 | () => this.meanVolumeIsSaving.set('error') 89 | ); 90 | } 91 | } 92 | 93 | setMaxVolume(volume: string, guildId: string) { 94 | if (volume.length > 0 && +volume > -30 && +volume < 30) { 95 | this.maxVolumeIsSaving.set('saving'); 96 | this.guildSettingsService.updateGuildSettings(guildId, { targetMaxVolume: +volume }).subscribe( 97 | () => this.maxVolumeIsSaving.set('saved'), 98 | () => this.maxVolumeIsSaving.set('error') 99 | ); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /frontend/src/app/keybind-generator/keybind-generator.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 |
9 |
10 | 11 |
12 |

This tool generates a script that can be used with AutoHotkey to 14 | automatically play sounds when a key combination on your computer is pressed.

16 |

The generated script contains a personal auth token for your account. Do not share it with others. You can regenerate it manually, 18 | making all previously downloaded scripts invalid.

20 |

21 | 22 | No auth token generated. 23 | 24 | 25 | 26 | Auth token generated on {{ authToken().createdAt * 1000 | date : 'short' }} 27 | 28 | 29 |

30 |
31 | 32 | 33 | 34 | drag_handle 35 | 36 | 37 | 38 | Key combination 39 | 45 | 46 | 47 | 48 | Play on server 49 | 50 | 51 | {{ guild.name || guild.id }} 52 | 53 | 54 | 55 | 56 | 57 | Command 58 | 59 | 64 | 65 | 66 | 67 | 68 | Actions 69 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | 79 |
80 | 81 | 84 |
85 |
86 | 87 | 88 | -------------------------------------------------------------------------------- /frontend/src/app/recorder/recorder.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | Discord server 8 | 9 | {{ guild.name || guild.id }} 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 |
20 |

The soundboard is continuously recording while it is connected to a voice channel. By pressing the button above or issuing the chat 22 | command ~record, the last 60s of recorded audio is saved.

24 | 25 | 26 | 27 | 28 | 33 | 34 | {{ getUsernames(recording.users) }}, Duration {{ recording.length | number : '1.0-0' }}s 35 | 36 | 37 | 38 | 39 | 40 |
41 | Select audio tracks 42 | {{ user.username }} 48 |
49 |
50 | Trim recording 51 | 52 | 53 | 54 | 55 |
56 | 57 |
58 | 59 | 62 | 63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 83 | 84 |
85 |
86 |
87 |

No recordings present.

88 |
89 |
90 |
91 | 92 | 93 | -------------------------------------------------------------------------------- /frontend/src/app/settings/sound-manager/sound-details/sound-details.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ sound().name }}{{ soundEntry.hasChanges() ? '*' : '' }} 5 | error_outline 8 | 9 | {{ sound().category }} 10 | 11 | 12 | 13 |
14 | 15 | Name 16 | 17 | 18 | 19 | Category 20 | 21 | 22 |
23 | 24 |
25 | 26 | Volume adjustment 27 | 28 | Automatic 29 | Manual 30 | 31 | 32 | 33 | Adjustment value 34 | 43 | dB 44 | 45 |
46 | 47 | 48 | 49 |
50 |

Statistics

51 |
52 |
53 |
Created
54 |
55 |
56 | 57 |
58 |
Max Volume
59 |
{{ sound().soundFile.maxVolume | number : '1.1-1' }} dB
60 |
61 |
62 |
Mean Volume
63 |
{{ sound().soundFile.meanVolume | number : '1.1-1' }} dB
64 |
65 |
66 |
Length
67 |
{{ sound().soundFile.length | number : '1.3-3' }} s
68 |
69 |
70 |
Uploaded
71 |
76 |
77 |
78 | 79 |
No sound file uploaded
80 |
81 |
82 |
83 |
84 | 85 | 86 | 87 | 90 |
91 | 94 |
95 | 96 |
97 |
98 |
99 | 102 |
103 | 104 |
105 |
106 |
107 |
108 | -------------------------------------------------------------------------------- /frontend/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "discord-soundboard-bot": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/discord-soundboard-bot", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": ["src/favicon.ico", "src/assets"], 26 | "styles": ["src/styles.scss"], 27 | "stylePreprocessorOptions": { 28 | "includePaths": ["src/styles"] 29 | }, 30 | "scripts": [], 31 | "vendorChunk": true, 32 | "extractLicenses": false, 33 | "buildOptimizer": false, 34 | "sourceMap": true, 35 | "optimization": false, 36 | "namedChunks": true, 37 | "baseHref": "/" 38 | }, 39 | "configurations": { 40 | "production": { 41 | "fileReplacements": [ 42 | { 43 | "replace": "src/environments/environment.ts", 44 | "with": "src/environments/environment.prod.ts" 45 | } 46 | ], 47 | "optimization": true, 48 | "outputHashing": "all", 49 | "sourceMap": false, 50 | "namedChunks": false, 51 | "extractLicenses": true, 52 | "vendorChunk": false, 53 | "buildOptimizer": true, 54 | "budgets": [ 55 | { 56 | "type": "initial", 57 | "maximumWarning": "2mb", 58 | "maximumError": "5mb" 59 | }, 60 | { 61 | "type": "anyComponentStyle", 62 | "maximumWarning": "6kb", 63 | "maximumError": "10kb" 64 | } 65 | ] 66 | } 67 | }, 68 | "defaultConfiguration": "" 69 | }, 70 | "serve": { 71 | "builder": "@angular-devkit/build-angular:dev-server", 72 | "options": { 73 | "browserTarget": "discord-soundboard-bot:build", 74 | "proxyConfig": "proxy.config.js" 75 | }, 76 | "configurations": { 77 | "production": { 78 | "browserTarget": "discord-soundboard-bot:build:production" 79 | } 80 | } 81 | }, 82 | "extract-i18n": { 83 | "builder": "@angular-devkit/build-angular:extract-i18n", 84 | "options": { 85 | "browserTarget": "discord-soundboard-bot:build" 86 | } 87 | }, 88 | "test": { 89 | "builder": "@angular-devkit/build-angular:karma", 90 | "options": { 91 | "main": "src/test.ts", 92 | "polyfills": "src/polyfills.ts", 93 | "tsConfig": "tsconfig.spec.json", 94 | "karmaConfig": "karma.conf.js", 95 | "assets": ["src/favicon.ico", "src/assets"], 96 | "styles": ["src/styles.scss"], 97 | "scripts": [] 98 | } 99 | }, 100 | "lint": { 101 | "builder": "@angular-eslint/builder:lint", 102 | "options": { 103 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 104 | } 105 | }, 106 | "e2e": { 107 | "builder": "@angular-devkit/build-angular:protractor", 108 | "options": { 109 | "protractorConfig": "e2e/protractor.conf.js", 110 | "devServerTarget": "discord-soundboard-bot:serve" 111 | }, 112 | "configurations": { 113 | "production": { 114 | "devServerTarget": "discord-soundboard-bot:serve:production" 115 | } 116 | } 117 | } 118 | } 119 | } 120 | }, 121 | "cli": { 122 | "schematicCollections": ["@angular-eslint/schematics"], 123 | "analytics": false 124 | }, 125 | "schematics": { 126 | "@angular-eslint/schematics:application": { 127 | "setParserOptionsProject": true 128 | }, 129 | "@angular-eslint/schematics:library": { 130 | "setParserOptionsProject": true 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/soundboard.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | Search sounds… 8 | 15 | 18 | 19 | 20 | 21 | Filter by category 22 | 23 | {{ category || '-- No category --' }} 24 | 25 | 26 | 27 | Discord server 28 | 29 | 30 | {{ guild.name ?? guild.id }} 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 | 44 |
45 | 46 | {{ event.timestamp * 1000 | date : 'mediumTime' }}: {{ event.userName }} {{ event | eventDescription }} 47 | 48 | No event to display 49 |
50 | 51 |
52 | 53 | 57 | 60 | 63 |
64 |
65 | 66 |
67 |
68 | 69 | shuffle {{ infix.displayName | uppercase }} 76 | 77 | {{ sound.name }} 87 |
88 |
89 | 90 | 91 |
92 | error_outline 93 |
94 | You are currently not in any Discord servers with the Soundboard bot. Try adding the bot to your server by clicking on your avatar 95 | in the toolbar. 96 |
97 |
98 | There are currently no sounds uploaded to your Discord servers. Try to upload some sounds via the settings or ask your server 99 | administrator to do so. 100 |
101 |
102 |
103 |
104 | 105 | 106 | -------------------------------------------------------------------------------- /frontend/src/app/recorder/recorder.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, QueryList, signal, ViewChild, ViewChildren } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | import { clamp } from 'lodash-es'; 4 | import { WebAudioBufferSource, WebAudioContext, WebAudioGain } from '@ng-web-apis/audio'; 5 | import { Observable } from 'rxjs'; 6 | import { map } from 'rxjs/operators'; 7 | import { ApiService } from '../services/api.service'; 8 | import { AppSettingsService } from '../services/app-settings.service'; 9 | import { RecorderService, Recording as SrvRecording, RecordingUser } from '../services/recorder.service'; 10 | 11 | interface Recording extends SrvRecording { 12 | selected: boolean[]; 13 | start: number; 14 | end: number; 15 | } 16 | 17 | @Component({ 18 | templateUrl: './recorder.component.html', 19 | styleUrls: ['./recorder.component.scss'], 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | }) 22 | export class RecorderComponent { 23 | get settings() { 24 | return this.settingsService.settings; 25 | } 26 | 27 | readonly user = this.apiService.user(); 28 | 29 | data$: Observable; 30 | 31 | readonly recordings = signal(null); 32 | readonly shownRecordings = computed(() => { 33 | const guildId = this.settings.guildId(); 34 | return this.recordings().filter(recording => recording.guildId === guildId); 35 | }); 36 | 37 | @ViewChildren(WebAudioBufferSource) audioBufferSources: QueryList; 38 | @ViewChild(WebAudioGain) gainNode: WebAudioGain; 39 | @ViewChild(WebAudioContext) contextNode: WebAudioContext; 40 | 41 | readonly gain = computed(() => clamp(this.settings.localVolume() / 100, 0, 1)); 42 | readonly currentlyPlaying = signal(null); 43 | 44 | constructor( 45 | private apiService: ApiService, 46 | private recorderService: RecorderService, 47 | private settingsService: AppSettingsService, 48 | private snackBar: MatSnackBar, 49 | private cdRef: ChangeDetectorRef 50 | ) { 51 | this.reload(); 52 | } 53 | 54 | reload() { 55 | this.data$ = this.recorderService 56 | .loadRecordings() 57 | .pipe( 58 | map(recordings => 59 | recordings 60 | .sort((a, b) => b.timestamp - a.timestamp) 61 | .map(recording => ({ ...recording, selected: recording.users.map(_ => true), start: 0, end: recording.length })) 62 | ) 63 | ); 64 | } 65 | 66 | deleteRecording(recording: Recording) { 67 | this.recorderService.deleteRecording(recording).subscribe({ 68 | next: () => { 69 | this.recordings.mutate(recordings => { 70 | recordings.splice(recordings.indexOf(recording), 1); 71 | }); 72 | this.snackBar.open('Recording deleted!', undefined, { duration: 1500 }); 73 | }, 74 | error: () => { 75 | this.snackBar.open('Failed to delete recording.', 'Damn', { duration: undefined }); 76 | this.reload(); 77 | }, 78 | }); 79 | } 80 | 81 | record() { 82 | this.snackBar.open(`Preparing recording. This may take up to one minute.`); 83 | this.recorderService.record(this.settings.guildId()).subscribe({ 84 | next: () => { 85 | this.snackBar.open(`Recording saved!`, undefined, { duration: 1500 }); 86 | this.reload(); 87 | this.cdRef.markForCheck(); 88 | }, 89 | error: error => { 90 | if (error.status === 404) { 91 | this.snackBar.open('No data to be saved. Is the bot in a voice channel?'); 92 | } else { 93 | this.snackBar.open('Unknown error while saving.', 'Damn', { duration: undefined }); 94 | } 95 | }, 96 | }); 97 | } 98 | 99 | getUsernames(users: RecordingUser[]) { 100 | return users.map(user => user.username).join(', '); 101 | } 102 | 103 | play(recording: Recording) { 104 | this.currentlyPlaying.set(recording); 105 | 106 | setTimeout(() => { 107 | const playTime = this.contextNode.currentTime + 0.1; 108 | this.audioBufferSources.forEach(source => { 109 | source.start(playTime, recording.start, recording.end - recording.start); 110 | }); 111 | }); 112 | } 113 | 114 | stop() { 115 | this.currentlyPlaying.set(null); 116 | } 117 | 118 | downloadMix(recording: Recording) { 119 | this.recorderService 120 | .mixRecording(recording, { 121 | start: recording.start, 122 | end: recording.end, 123 | userIds: recording.users.filter((_, i) => recording.selected[i]).map(user => user.id), 124 | }) 125 | .subscribe({ 126 | next: data => { 127 | window.open(data.downloadUrl, '_blank'); 128 | }, 129 | error: () => { 130 | this.snackBar.open('Unknown error when mixing.', 'Damn', { duration: undefined }); 131 | }, 132 | }); 133 | } 134 | 135 | formatTrimSlider(value: number) { 136 | return `${value.toFixed(1)}s`; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /backend/src/discord/management.rs: -------------------------------------------------------------------------------- 1 | use crate::db::DbConn; 2 | use crate::CacheHttp; 3 | use bigdecimal::BigDecimal; 4 | use bigdecimal::FromPrimitive; 5 | use bigdecimal::ToPrimitive; 6 | use diesel::prelude::*; 7 | use diesel::result::Error as DieselError; 8 | use serenity::model::guild::Guild; 9 | use serenity::model::guild::Member; 10 | use serenity::model::id::GuildId; 11 | use serenity::model::id::UserId; 12 | 13 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 14 | pub enum UserPermission { 15 | Admin, 16 | Moderator, 17 | User, 18 | } 19 | 20 | #[derive(Debug)] 21 | pub struct PermissionResponse { 22 | pub permission: UserPermission, 23 | pub member: Member, 24 | } 25 | 26 | pub enum PermissionError { 27 | InsufficientPermission, 28 | DieselError(DieselError), 29 | BigDecimalError, 30 | } 31 | 32 | impl From for PermissionError { 33 | fn from(err: DieselError) -> Self { 34 | PermissionError::DieselError(err) 35 | } 36 | } 37 | 38 | pub async fn check_guild_user( 39 | cache_http: &CacheHttp, 40 | db: &DbConn, 41 | user_id: UserId, 42 | guild_id: GuildId, 43 | ) -> Result { 44 | get_permission_level(cache_http, db, user_id, guild_id).await 45 | } 46 | 47 | pub async fn check_guild_moderator( 48 | cache_http: &CacheHttp, 49 | db: &DbConn, 50 | user_id: UserId, 51 | guild_id: GuildId, 52 | ) -> Result { 53 | let response = get_permission_level(cache_http, db, user_id, guild_id).await?; 54 | 55 | if response.permission == UserPermission::Admin 56 | || response.permission == UserPermission::Moderator 57 | { 58 | Ok(response) 59 | } else { 60 | Err(PermissionError::InsufficientPermission) 61 | } 62 | } 63 | 64 | pub async fn check_guild_admin( 65 | cache_http: &CacheHttp, 66 | user_id: UserId, 67 | guild_id: GuildId, 68 | ) -> Result<(), PermissionError> { 69 | let member = guild_id 70 | .member(cache_http, user_id) 71 | .await 72 | .map_err(|_| PermissionError::InsufficientPermission)?; 73 | 74 | member 75 | .permissions(cache_http) 76 | .ok() 77 | .and_then(|perms| { 78 | if perms.administrator() { 79 | Some(()) 80 | } else { 81 | None 82 | } 83 | }) 84 | .ok_or(PermissionError::InsufficientPermission) 85 | } 86 | 87 | pub async fn get_permission_level( 88 | cache_http: &CacheHttp, 89 | db: &DbConn, 90 | user_id: UserId, 91 | guild_id: GuildId, 92 | ) -> Result { 93 | let member = guild_id 94 | .member(cache_http, user_id) 95 | .await 96 | .map_err(|_| PermissionError::InsufficientPermission)?; 97 | 98 | if member 99 | .permissions(cache_http) 100 | .map(|perms| perms.administrator()) 101 | .unwrap_or(false) 102 | { 103 | return Ok(PermissionResponse { 104 | member, 105 | permission: UserPermission::Admin, 106 | }); 107 | } 108 | 109 | let gid = BigDecimal::from_u64(guild_id.0).ok_or(PermissionError::BigDecimalError)?; 110 | let (user_role_id, moderator_role_id) = db 111 | .run(move |c| { 112 | use crate::db::schema::guildsettings::dsl::*; 113 | guildsettings 114 | .find(gid) 115 | .select((user_role_id, moderator_role_id)) 116 | .first::<(Option, Option)>(c) 117 | .optional() 118 | }) 119 | .await? 120 | .unwrap_or((None, None)); 121 | 122 | if let Some(moderator_role_id) = moderator_role_id { 123 | let rid = moderator_role_id 124 | .to_u64() 125 | .ok_or(PermissionError::BigDecimalError)?; 126 | 127 | if member.roles.iter().any(|role| role.0 == rid) { 128 | return Ok(PermissionResponse { 129 | member, 130 | permission: UserPermission::Moderator, 131 | }); 132 | } 133 | } 134 | 135 | if let Some(user_role_id) = user_role_id { 136 | let rid = user_role_id 137 | .to_u64() 138 | .ok_or(PermissionError::BigDecimalError)?; 139 | 140 | if member.roles.iter().any(|role| role.0 == rid) { 141 | return Ok(PermissionResponse { 142 | member, 143 | permission: UserPermission::User, 144 | }); 145 | } 146 | } 147 | 148 | Err(PermissionError::InsufficientPermission) 149 | } 150 | 151 | #[instrument(skip(cache_http, db), err)] 152 | pub async fn get_guilds_for_user( 153 | cache_http: &CacheHttp, 154 | db: &DbConn, 155 | user_id: UserId, 156 | ) -> Result, serenity::Error> { 157 | let mut response = vec![]; 158 | for guild_id in cache_http.cache.guilds() { 159 | if let Ok(perm) = get_permission_level(cache_http, db, user_id, guild_id).await { 160 | if let Some(guild) = guild_id.to_guild_cached(&cache_http.cache) { 161 | response.push((guild, perm.permission)); 162 | } 163 | } 164 | } 165 | Ok(response) 166 | } 167 | -------------------------------------------------------------------------------- /frontend/src/app/settings/guild-settings/guild-settings.component.html: -------------------------------------------------------------------------------- 1 | 2 |
Settings for {{ guild().name }}
7 | 8 |
9 | 10 | 11 |

admin_panel_settings User

12 |

You can define a role from your server to be the soundboard user role. Only users with that role (or moderator or admin role) can 14 | view and play sounds as well as create, view and delete recordings on your server.

16 |
17 | 18 | User role 19 | 20 | -- Disable users -- 21 | {{ role.value }} 22 | 23 | 24 | 25 |
26 | 27 |

admin_panel_settings Moderator

28 |

You can define a role from your server to be the moderator role. Every user with that role will be able to edit the random buttons 30 | and sounds within your server.

32 |
33 | 34 | Moderator role 35 | 36 | -- Disable moderators -- 37 | {{ role.value }} 38 | 39 | 40 | 41 |
42 | 43 |

The soundboard automatically boosts the volume of sounds that are too quiet. The server-specific values to which the sounds are 45 | boosted can be defined below. Those settings can also be overriden manually for each sound.

47 |
48 | 49 | Target mean volume 50 | 60 | dB 61 | 62 | info 63 | 64 |
65 |
66 | 67 | Target max volume 68 | 78 | dB 79 | 80 | info 81 | 82 |
83 | 84 | 85 | 86 | 87 | error_outline 88 | check 89 | 90 | 91 |
92 | 93 |

shuffle Random buttons

94 |

You can define what "random" buttons are displayed for users of your server.

95 | 100 |
101 |
102 | 103 | 109 | -------------------------------------------------------------------------------- /backend/src/api/events.rs: -------------------------------------------------------------------------------- 1 | use crate::api::auth::TokenUserId; 2 | use crate::api::utils::AvatarOrDefault; 3 | use crate::api::Snowflake; 4 | use crate::db::models::Sound; 5 | use crate::db::DbConn; 6 | use crate::discord::management::check_guild_user; 7 | use crate::CacheHttp; 8 | use rocket::http::Status; 9 | use rocket::response::stream::Event; 10 | use rocket::response::stream::EventStream; 11 | use rocket::Route; 12 | use rocket::Shutdown; 13 | use rocket::State; 14 | use serde::Deserialize; 15 | use serde::Serialize; 16 | use serde_with::serde_as; 17 | use serde_with::TimestampSeconds; 18 | use serenity::model::guild::Member; 19 | use serenity::model::id::GuildId; 20 | use std::time::SystemTime; 21 | use tokio::select; 22 | use tokio::sync::broadcast::channel; 23 | use tokio::sync::broadcast::error::RecvError; 24 | use tokio::sync::broadcast::Receiver; 25 | use tokio::sync::broadcast::Sender; 26 | 27 | pub fn get_routes() -> Vec { 28 | routes![events] 29 | } 30 | 31 | #[derive(Debug, Serialize, Deserialize, Clone)] 32 | #[serde(rename_all = "camelCase")] 33 | struct PlaybackStartedData { 34 | #[serde(flatten)] 35 | target_data: EventData, 36 | sound_name: String, 37 | } 38 | 39 | #[derive(Debug, Serialize, Deserialize, Clone)] 40 | #[serde(rename_all = "camelCase")] 41 | struct ChannelJoinedData { 42 | #[serde(flatten)] 43 | target_data: EventData, 44 | channel_name: String, 45 | } 46 | 47 | #[serde_as] 48 | #[derive(Debug, Serialize, Deserialize, Clone)] 49 | #[serde(rename_all = "camelCase")] 50 | pub struct EventData { 51 | guild_id: Snowflake, 52 | user_name: String, 53 | user_avatar_url: String, 54 | #[serde_as(as = "TimestampSeconds")] 55 | timestamp: SystemTime, 56 | } 57 | 58 | #[derive(Debug, Serialize, Deserialize, Clone)] 59 | #[serde(tag = "type")] 60 | enum EventMessage { 61 | PlaybackStarted(PlaybackStartedData), 62 | PlaybackStopped(EventData), 63 | RecordingSaved(EventData), 64 | JoinedChannel(ChannelJoinedData), 65 | LeftChannel(EventData), 66 | } 67 | 68 | pub struct EventBus { 69 | sender: Sender<(GuildId, EventMessage)>, 70 | } 71 | 72 | impl EventBus { 73 | pub fn new() -> Self { 74 | Self { 75 | sender: channel::<(GuildId, EventMessage)>(1024).0, 76 | } 77 | } 78 | 79 | pub fn playback_started(&self, member: &Member, sound: &Sound) { 80 | // If sending the event fails, we ignore it 81 | let _ = self.sender.send(( 82 | member.guild_id, 83 | EventMessage::PlaybackStarted(PlaybackStartedData { 84 | target_data: EventData::new(member), 85 | sound_name: sound.name.clone(), 86 | }), 87 | )); 88 | } 89 | 90 | pub fn playback_stopped(&self, member: &Member) { 91 | let _ = self.sender.send(( 92 | member.guild_id, 93 | EventMessage::PlaybackStopped(EventData::new(member)), 94 | )); 95 | } 96 | 97 | pub fn recording_saved(&self, member: &Member) { 98 | let _ = self.sender.send(( 99 | member.guild_id, 100 | EventMessage::RecordingSaved(EventData::new(member)), 101 | )); 102 | } 103 | 104 | pub fn channel_joined(&self, member: &Member, channel_name: String) { 105 | let _ = self.sender.send(( 106 | member.guild_id, 107 | EventMessage::JoinedChannel(ChannelJoinedData { 108 | target_data: EventData::new(member), 109 | channel_name, 110 | }), 111 | )); 112 | } 113 | 114 | pub fn channel_left(&self, member: &Member) { 115 | let _ = self.sender.send(( 116 | member.guild_id, 117 | EventMessage::LeftChannel(EventData::new(member)), 118 | )); 119 | } 120 | 121 | fn subscribe(&self) -> Receiver<(GuildId, EventMessage)> { 122 | self.sender.subscribe() 123 | } 124 | } 125 | 126 | impl EventData { 127 | fn new(member: &Member) -> Self { 128 | Self { 129 | guild_id: Snowflake(member.guild_id.0), 130 | user_name: member 131 | .nick 132 | .clone() 133 | .unwrap_or_else(|| member.user.name.clone()), 134 | user_avatar_url: member.avatar_url_or_default(), 135 | timestamp: SystemTime::now(), 136 | } 137 | } 138 | } 139 | 140 | #[get("//events")] 141 | async fn events( 142 | guild_id: u64, 143 | cache_http: &State, 144 | event_bus: &State, 145 | db: DbConn, 146 | user: TokenUserId, 147 | mut end: Shutdown, 148 | ) -> Result { 149 | // Only users may get events from this guild 150 | let serenity_user = user.into(); 151 | check_guild_user(cache_http.inner(), &db, serenity_user, GuildId(guild_id)) 152 | .await 153 | .map_err(|_| Status::Forbidden)?; 154 | 155 | let mut rx = event_bus.subscribe(); 156 | Ok(EventStream! { 157 | loop { 158 | let (msg_guild, msg) = select! { 159 | msg = rx.recv() => match msg { 160 | Ok(msg) => msg, 161 | Err(RecvError::Closed) => break, 162 | Err(RecvError::Lagged(_)) => continue, 163 | }, 164 | _ = &mut end => break, 165 | }; 166 | 167 | if msg_guild.0 == guild_id { 168 | yield Event::json(&msg); 169 | } 170 | } 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /backend/src/discord/client.rs: -------------------------------------------------------------------------------- 1 | use crate::discord::recorder::Recorder; 2 | use crate::discord::CacheHttp; 3 | use serenity::client::ClientBuilder; 4 | use serenity::client::Context; 5 | use serenity::model::id::ChannelId; 6 | use serenity::model::id::GuildId; 7 | use serenity::model::id::UserId; 8 | use serenity::prelude::TypeMapKey; 9 | use songbird::driver::DecodeMode; 10 | use songbird::error::JoinError; 11 | use songbird::Config as DriverConfig; 12 | use songbird::SerenityInit; 13 | use songbird::Songbird; 14 | use std::path::PathBuf; 15 | use std::sync::Arc; 16 | use tokio::sync::Mutex; 17 | 18 | #[derive(Debug)] 19 | pub enum ClientError { 20 | NotInAChannel, 21 | UserNotFound, 22 | DecodingError(songbird::input::error::Error), 23 | ConnectionError, 24 | GuildNotFound, 25 | } 26 | 27 | #[derive(Clone)] 28 | pub struct Client { 29 | songbird: Arc, 30 | pub recorder: Arc, 31 | } 32 | 33 | impl Client { 34 | #[instrument] 35 | pub fn new() -> Self { 36 | let songbird = Songbird::serenity(); 37 | songbird.set_config(DriverConfig::default().decode_mode(DecodeMode::Decode)); 38 | 39 | Self { 40 | songbird, 41 | recorder: Recorder::create(), 42 | } 43 | } 44 | 45 | #[instrument(skip(self))] 46 | pub async fn join_channel( 47 | &self, 48 | guild_id: GuildId, 49 | channel_id: ChannelId, 50 | ) -> Result>, ClientError> { 51 | let (call_lock, result) = self.songbird.join(guild_id, channel_id).await; 52 | result.map_err(|_| ClientError::ConnectionError)?; 53 | 54 | self.recorder 55 | .register_with_call(guild_id, call_lock.clone()) 56 | .await; 57 | 58 | Ok(call_lock) 59 | } 60 | 61 | #[instrument(skip(self, cache_and_http))] 62 | pub async fn join_user( 63 | &self, 64 | guild_id: GuildId, 65 | user_id: UserId, 66 | cache_and_http: &CacheHttp, 67 | ) -> Result<(ChannelId, Arc>), ClientError> { 68 | let guild = guild_id 69 | .to_guild_cached(cache_and_http) 70 | .ok_or(ClientError::GuildNotFound)?; 71 | 72 | let channel_id = guild 73 | .voice_states 74 | .get(&user_id) 75 | .and_then(|voice_state| voice_state.channel_id) 76 | .ok_or(ClientError::UserNotFound)?; 77 | 78 | debug!(?channel_id, "Joining user in channel"); 79 | 80 | self.join_channel(guild_id, channel_id) 81 | .await 82 | .map(|call| (channel_id, call)) 83 | } 84 | 85 | #[instrument(skip(self))] 86 | pub async fn leave(&self, guild_id: GuildId) -> Result<(), ClientError> { 87 | self.songbird 88 | .remove(guild_id) 89 | .await 90 | .map_err(|err| match err { 91 | JoinError::NoCall => ClientError::NotInAChannel, 92 | _ => ClientError::ConnectionError, 93 | }) 94 | } 95 | 96 | #[instrument(skip(self))] 97 | pub async fn play( 98 | &self, 99 | sound_path: &PathBuf, 100 | volume_adjustment: f32, 101 | guild_id: GuildId, 102 | ) -> Result<(), ClientError> { 103 | let call_lock = self 104 | .songbird 105 | .get(guild_id) 106 | .ok_or(ClientError::NotInAChannel)?; 107 | let mut call = call_lock.lock().await; 108 | 109 | let volume_adjustment_string = format!("volume={}dB", volume_adjustment); 110 | let source = songbird::input::ffmpeg_optioned( 111 | sound_path, 112 | &[], 113 | &[ 114 | "-f", 115 | "s16le", 116 | "-ar", 117 | "48000", 118 | "-acodec", 119 | "pcm_f32le", 120 | "-filter:a", 121 | &volume_adjustment_string, 122 | "-", 123 | ], 124 | ) 125 | .await 126 | .map_err(|why| { 127 | warn!("Err starting source: {:?}", why); 128 | ClientError::DecodingError(why) 129 | })?; 130 | 131 | call.play_source(source); 132 | Ok(()) 133 | } 134 | 135 | #[instrument(skip(self))] 136 | pub async fn stop(&self, guild_id: GuildId) -> Result<(), ClientError> { 137 | let handler_lock = self 138 | .songbird 139 | .get(guild_id) 140 | .ok_or(ClientError::NotInAChannel)?; 141 | 142 | let mut handler = handler_lock.lock().await; 143 | handler.stop(); 144 | 145 | Ok(()) 146 | } 147 | } 148 | 149 | /// Helper trait to add installation/creation methods to serenity's 150 | /// `ClientBuilder`. 151 | pub trait ClientInit { 152 | fn register_client(self, client: &Client) -> Self; 153 | } 154 | 155 | impl ClientInit for ClientBuilder { 156 | fn register_client(self, client: &Client) -> Self { 157 | self.type_map_insert::(client.clone()) 158 | .register_songbird_with(client.songbird.clone()) 159 | } 160 | } 161 | 162 | /// Key used to put the Client into the serenity TypeMap 163 | struct ClientKey; 164 | 165 | impl TypeMapKey for ClientKey { 166 | type Value = Client; 167 | } 168 | 169 | /// Retrieve the Client State from a serenity context's 170 | /// shared key-value store. 171 | pub async fn get(ctx: &Context) -> Option { 172 | let data = ctx.data.read().await; 173 | 174 | data.get::().cloned() 175 | } 176 | -------------------------------------------------------------------------------- /backend/src/discord/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::discord::client; 2 | use crate::discord::recorder::RecordingError; 3 | use crate::BASE_URL; 4 | use crate::BUILD_ID; 5 | use crate::BUILD_TIMESTAMP; 6 | use crate::VERSION; 7 | use serenity::client::Context; 8 | use serenity::framework::standard::macros::command; 9 | use serenity::framework::standard::macros::group; 10 | use serenity::framework::standard::CommandResult; 11 | use serenity::framework::StandardFramework; 12 | use serenity::model::channel::Message; 13 | use serenity::model::prelude::ReactionType; 14 | use serenity::model::prelude::UserId; 15 | use serenity::prelude::*; 16 | use serenity::Result as SerenityResult; 17 | use std::convert::TryFrom; 18 | use std::fmt::Write; 19 | 20 | /// Creates the framework used by the discord client 21 | pub fn create_framework(bot_id: UserId) -> StandardFramework { 22 | StandardFramework::new() 23 | .configure(|c| c.on_mention(Some(bot_id)).prefix("~")) 24 | .group(&GENERAL_GROUP) 25 | } 26 | 27 | #[group] 28 | #[commands(join, leave, stop, ping, record, guildid, info)] 29 | struct General; 30 | 31 | #[command] 32 | #[only_in(guilds)] 33 | async fn join(ctx: &Context, msg: &Message) -> CommandResult { 34 | let guild_id = msg.guild_id.unwrap(); 35 | 36 | let client = client::get(ctx) 37 | .await 38 | .expect("Discord client placed in at initialization"); 39 | 40 | match client.join_user(guild_id, msg.author.id, &ctx.into()).await { 41 | Ok((channel_id, _)) => check_msg( 42 | msg.channel_id 43 | .say( 44 | &ctx.http, 45 | &format!(":white_check_mark: Joined {}", channel_id.mention()), 46 | ) 47 | .await, 48 | ), 49 | Err(client::ClientError::UserNotFound) => { 50 | check_msg(msg.reply(&ctx, ":x: Not in a voice channel").await) 51 | } 52 | _ => check_msg(msg.reply(&ctx, ":x: Connection error").await), 53 | }; 54 | 55 | Ok(()) 56 | } 57 | 58 | #[command] 59 | #[only_in(guilds)] 60 | async fn leave(ctx: &Context, msg: &Message) -> CommandResult { 61 | let guild_id = msg.guild_id.unwrap(); 62 | 63 | let client = client::get(ctx) 64 | .await 65 | .expect("Discord client placed in at initialization"); 66 | 67 | match client.leave(guild_id).await { 68 | Ok(_) => check_msg(msg.channel_id.say(&ctx.http, "Left voice channel").await), 69 | Err(client::ClientError::NotInAChannel) => { 70 | check_msg(msg.reply(&ctx, ":x: Not in a voice channel").await) 71 | } 72 | _ => check_msg(msg.reply(&ctx, ":x: Connection error").await), 73 | } 74 | 75 | Ok(()) 76 | } 77 | 78 | #[command] 79 | async fn ping(ctx: &Context, msg: &Message) -> CommandResult { 80 | check_msg(msg.channel_id.say(&ctx.http, "Pong!").await); 81 | Ok(()) 82 | } 83 | 84 | #[command] 85 | #[only_in(guilds)] 86 | async fn stop(ctx: &Context, msg: &Message) -> CommandResult { 87 | let guild_id = msg.guild_id.unwrap(); 88 | 89 | let client = client::get(ctx) 90 | .await 91 | .expect("Discord client placed in at initialisation."); 92 | 93 | match client.stop(guild_id).await { 94 | Ok(_) => check_msg(msg.channel_id.say(&ctx.http, ":stop_button: Stopped").await), 95 | Err(client::ClientError::NotInAChannel) => check_msg( 96 | msg.channel_id 97 | .say(&ctx.http, ":x: Not in a voice channel to play in") 98 | .await, 99 | ), 100 | _ => unreachable!(), 101 | } 102 | 103 | Ok(()) 104 | } 105 | 106 | #[command] 107 | #[only_in(guilds)] 108 | async fn record(ctx: &Context, msg: &Message) -> CommandResult { 109 | let reaction = msg 110 | .react(&ctx.http, ReactionType::try_from("⏬").unwrap()) 111 | .await; 112 | 113 | let guild_id = msg.guild_id.unwrap(); 114 | let client = client::get(ctx) 115 | .await 116 | .expect("Recorder placed in at initialization"); 117 | 118 | match client.recorder.save_recording(guild_id, &ctx.into()).await { 119 | Ok(_) => check_msg( 120 | msg.channel_id 121 | .say(&ctx.http, ":white_check_mark: Recording saved") 122 | .await, 123 | ), 124 | Err(err) => { 125 | error!(?err, "Failed to record"); 126 | match err { 127 | RecordingError::IoError(_) => check_msg( 128 | msg.channel_id 129 | .say(&ctx.http, ":x: Failed to save recording") 130 | .await, 131 | ), 132 | RecordingError::NoData => { 133 | check_msg(msg.channel_id.say(&ctx.http, ":x: No data to record").await) 134 | } 135 | } 136 | } 137 | } 138 | 139 | if let Ok(reaction) = reaction { 140 | let _ = reaction.delete(&ctx.http).await; 141 | } 142 | 143 | Ok(()) 144 | } 145 | 146 | #[command] 147 | #[only_in(guilds)] 148 | async fn guildid(ctx: &Context, msg: &Message) -> CommandResult { 149 | let guild_id = msg.guild_id.unwrap(); 150 | 151 | check_msg( 152 | msg.channel_id 153 | .say(&ctx.http, format!("GuildId: `{}`", guild_id)) 154 | .await, 155 | ); 156 | 157 | Ok(()) 158 | } 159 | 160 | #[command] 161 | async fn info(ctx: &Context, msg: &Message) -> CommandResult { 162 | let mut resp = format!( 163 | "discord-soundboard-bot v{}\n\ 164 | Control at {}\n\ 165 | Source code at https://github.com/dominikks/discord-soundboard-bot", 166 | VERSION, 167 | BASE_URL.clone() 168 | ); 169 | if let (Some(bid), Some(bt)) = (BUILD_ID, BUILD_TIMESTAMP) { 170 | write!(resp, "\n\nbuild {}, timestamp {}", bid, bt)?; 171 | } 172 | check_msg(msg.channel_id.say(&ctx.http, &resp).await); 173 | 174 | Ok(()) 175 | } 176 | 177 | /// Checks that a message successfully sent; if not, then logs why to stdout. 178 | #[instrument] 179 | fn check_msg(result: SerenityResult) { 180 | if let Err(why) = result { 181 | error!("Error sending message: {:?}", why); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /frontend/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { LOCALE_ID, NgModule } from '@angular/core'; 3 | import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatCardModule } from '@angular/material/card'; 7 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 8 | import { MatFormFieldModule } from '@angular/material/form-field'; 9 | import { MatSliderModule } from '@angular/material/slider'; 10 | import { MatIconModule } from '@angular/material/icon'; 11 | import { MatInputModule } from '@angular/material/input'; 12 | import { MAT_SNACK_BAR_DEFAULT_OPTIONS, MatSnackBarModule } from '@angular/material/snack-bar'; 13 | import { MatRippleModule } from '@angular/material/core'; 14 | import { MatDialogModule } from '@angular/material/dialog'; 15 | import { MatTableModule } from '@angular/material/table'; 16 | import { MatSidenavModule } from '@angular/material/sidenav'; 17 | import { MatSelectModule } from '@angular/material/select'; 18 | import { MatCheckboxModule } from '@angular/material/checkbox'; 19 | import { MatExpansionModule } from '@angular/material/expansion'; 20 | import { MatMenuModule } from '@angular/material/menu'; 21 | import { MatListModule } from '@angular/material/list'; 22 | import { MatToolbarModule } from '@angular/material/toolbar'; 23 | import { MatDividerModule } from '@angular/material/divider'; 24 | import { DragDropModule } from '@angular/cdk/drag-drop'; 25 | import { FormsModule } from '@angular/forms'; 26 | import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; 27 | import { TimeagoModule } from 'ngx-timeago'; 28 | import { MatTooltipModule } from '@angular/material/tooltip'; 29 | import { WebAudioModule } from '@ng-web-apis/audio'; 30 | import { ScrollingModule } from '@angular/cdk/scrolling'; 31 | import { SoundboardButtonComponent } from './soundboard/soundboard-button/soundboard-button.component'; 32 | import { KeybindGeneratorComponent } from './keybind-generator/keybind-generator.component'; 33 | import { KeyCombinationInputComponent } from './keybind-generator/keycombination-input/key-combination-input.component'; 34 | import { SearchableSoundSelectComponent } from './keybind-generator/searchable-sound-select/searchable-sound-select.component'; 35 | import { RecorderComponent } from './recorder/recorder.component'; 36 | import { FooterComponent } from './footer/footer.component'; 37 | import { HeaderComponent } from './header/header.component'; 38 | import { LoginComponent } from './login/login.component'; 39 | import { AuthInterceptorService } from './services/auth-interceptor.service'; 40 | import { SettingsComponent } from './settings/settings.component'; 41 | import { UserSettingsComponent } from './settings/user-settings/user-settings.component'; 42 | import { GuildSettingsComponent } from './settings/guild-settings/guild-settings.component'; 43 | import { RandomInfixesComponent } from './settings/random-infixes/random-infixes.component'; 44 | import { UnsavedChangesBoxComponent } from './settings/unsaved-changes-box/unsaved-changes-box.component'; 45 | import { SoundManagerComponent } from './settings/sound-manager/sound-manager.component'; 46 | import { SoundDetailsComponent } from './settings/sound-manager/sound-details/sound-details.component'; 47 | import { SoundDeleteConfirmComponent } from './settings/sound-manager/sound-delete-confirm/sound-delete-confirm.component'; 48 | import { GuildNamePipe } from './guild-name.pipe'; 49 | import { SoundboardComponent } from './soundboard/soundboard.component'; 50 | import { AppComponent } from './app.component'; 51 | import { AppRoutingModule } from './app-routing.module'; 52 | import { EventLogDialogComponent } from './soundboard/event-log-dialog/event-log-dialog.component'; 53 | import { ScrollIntoViewDirective } from './common/scroll-into-view.directive'; 54 | import { EventDescriptionPipe } from './event-description.pipe'; 55 | import { VolumeSliderComponent } from './volume-slider/volume-slider.component'; 56 | import { DataLoadDirective } from './data-load/data-load.directive'; 57 | import { DataLoadErrorComponent } from './data-load/data-load-error.component'; 58 | 59 | @NgModule({ 60 | declarations: [ 61 | AppComponent, 62 | SoundboardComponent, 63 | SoundboardButtonComponent, 64 | KeybindGeneratorComponent, 65 | KeyCombinationInputComponent, 66 | SearchableSoundSelectComponent, 67 | RecorderComponent, 68 | FooterComponent, 69 | HeaderComponent, 70 | LoginComponent, 71 | SettingsComponent, 72 | UserSettingsComponent, 73 | GuildSettingsComponent, 74 | RandomInfixesComponent, 75 | UnsavedChangesBoxComponent, 76 | SoundManagerComponent, 77 | SoundDetailsComponent, 78 | SoundDeleteConfirmComponent, 79 | GuildNamePipe, 80 | EventLogDialogComponent, 81 | ScrollIntoViewDirective, 82 | EventDescriptionPipe, 83 | VolumeSliderComponent, 84 | DataLoadDirective, 85 | DataLoadErrorComponent, 86 | ], 87 | imports: [ 88 | // Angular 89 | BrowserModule, 90 | HttpClientModule, 91 | AppRoutingModule, 92 | BrowserAnimationsModule, 93 | FormsModule, 94 | // Angular Material 95 | MatCardModule, 96 | MatButtonModule, 97 | MatProgressSpinnerModule, 98 | MatSliderModule, 99 | MatIconModule, 100 | MatSnackBarModule, 101 | MatFormFieldModule, 102 | MatInputModule, 103 | MatRippleModule, 104 | MatCheckboxModule, 105 | MatSelectModule, 106 | MatTableModule, 107 | MatDialogModule, 108 | DragDropModule, 109 | MatMenuModule, 110 | MatListModule, 111 | MatExpansionModule, 112 | MatDividerModule, 113 | MatTooltipModule, 114 | MatToolbarModule, 115 | MatSidenavModule, 116 | ScrollingModule, 117 | // Other Dependencies 118 | TimeagoModule.forRoot(), 119 | WebAudioModule, 120 | NgxMatSelectSearchModule, 121 | ], 122 | providers: [ 123 | { 124 | provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, 125 | useValue: { 126 | horizontalPosition: 'center', 127 | verticalPosition: 'top', 128 | duration: 5000, 129 | }, 130 | }, 131 | { 132 | provide: LOCALE_ID, 133 | useValue: 'en-US', 134 | }, 135 | { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptorService, multi: true }, 136 | ], 137 | bootstrap: [AppComponent], 138 | }) 139 | export class AppModule {} 140 | -------------------------------------------------------------------------------- /frontend/src/app/soundboard/soundboard.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core'; 2 | import { clamp, sample, sortBy, uniq } from 'lodash-es'; 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | import Fuse from 'fuse.js'; 5 | import { EMPTY, forkJoin } from 'rxjs'; 6 | import { shareReplay } from 'rxjs/operators'; 7 | import { MatDialog } from '@angular/material/dialog'; 8 | import { HttpErrorResponse } from '@angular/common/http'; 9 | import { AppSettingsService } from '../services/app-settings.service'; 10 | import { ApiService, RandomInfix } from '../services/api.service'; 11 | import { Sound, SoundsService } from '../services/sounds.service'; 12 | import { EventsService } from '../services/events.service'; 13 | import { EventLogDialogComponent } from './event-log-dialog/event-log-dialog.component'; 14 | 15 | @Component({ 16 | templateUrl: './soundboard.component.html', 17 | styleUrls: ['./soundboard.component.scss'], 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | }) 20 | export class SoundboardComponent { 21 | get settings() { 22 | return this.settingsService.settings; 23 | } 24 | 25 | readonly user = this.apiService.user(); 26 | 27 | readonly data$ = forkJoin([this.apiService.loadRandomInfixes(), this.soundsService.loadSounds()]); 28 | readonly loadedData = signal<[RandomInfix[], Sound[]]>(null); 29 | 30 | readonly randomInfixes = computed(() => this.loadedData()?.[0]); 31 | readonly sounds = computed(() => { 32 | const sounds = this.loadedData()[1]; 33 | return [sounds, new Fuse(sounds, { keys: ['name'] })] as [Sound[], Fuse]; 34 | }); 35 | readonly soundCategories = computed(() => { 36 | const sounds = this.sounds()[0]; 37 | return sortBy(uniq(sounds.map(sound => sound.category)), category => category.toLowerCase()); 38 | }); 39 | readonly filteredSounds = computed(() => { 40 | const sounds = this.sounds(); 41 | 42 | const searchFilter = this.soundSearchFilter(); 43 | const categories = this.settings.soundCategories(); 44 | 45 | if (searchFilter.length > 0) { 46 | return sounds[1].search(searchFilter).map(res => res.item); 47 | } else if (categories.length > 0) { 48 | return sounds[0].filter(sound => categories.includes(sound.category)); 49 | } else { 50 | return sounds[0]; 51 | } 52 | }); 53 | 54 | readonly currentAudio = signal(null); 55 | readonly currentLocalSound = signal(null); 56 | readonly soundSearchFilter = signal(''); 57 | 58 | readonly target = this.settings.guildId; 59 | 60 | readonly events$ = computed(() => { 61 | const target = this.target(); 62 | return target ? this.eventsService.getEventStream(target).pipe(shareReplay(100)) : EMPTY; 63 | }); 64 | 65 | constructor( 66 | private apiService: ApiService, 67 | private soundsService: SoundsService, 68 | private settingsService: AppSettingsService, 69 | private eventsService: EventsService, 70 | private snackBar: MatSnackBar, 71 | private dialog: MatDialog 72 | ) { 73 | // Update volume of HTMLAudioElement 74 | effect(() => { 75 | const audio = this.currentAudio(); 76 | if (audio) { 77 | audio.volume = clamp(this.settings.localVolume() / 100, 0, 1); 78 | } 79 | }); 80 | } 81 | 82 | playSound(sound: Sound) { 83 | this.stopLocalSound(); 84 | this.soundsService.playSound(sound, this.settings.guildId(), this.settings.autoJoin()).subscribe({ 85 | next: () => { 86 | if (this.settings.debug()) { 87 | let volString = 88 | sound.soundFile != null 89 | ? `Volume: Max ${sound.soundFile.maxVolume.toFixed(1)} dB, Average ${sound.soundFile.meanVolume.toFixed(1)} dB, ` 90 | : ''; 91 | volString += sound.volumeAdjustment != null ? `Manual adjustment ${sound.volumeAdjustment} dB` : 'Automatic adjustment'; 92 | this.snackBar.open(volString, 'Ok'); 93 | } 94 | }, 95 | error: (error: HttpErrorResponse) => { 96 | if (error.status === 400) { 97 | this.snackBar.open('Failed to join you. Are you in a voice channel that is visible to the bot?'); 98 | } else if (error.status === 503) { 99 | this.snackBar.open('The bot is currently not in a voice channel!'); 100 | } else if (error.status === 404) { 101 | this.snackBar.open('Sound not found. It might have been deleted or renamed.'); 102 | } else if (error.status >= 300) { 103 | this.snackBar.open('Unknown error playing the sound file.'); 104 | } 105 | }, 106 | }); 107 | } 108 | 109 | playLocalSound(sound: Sound) { 110 | this.stopLocalSound(); 111 | const audio = new Audio(); 112 | this.currentAudio.set(audio); 113 | this.currentLocalSound.set(sound); 114 | audio.src = sound.getDownloadUrl(); 115 | audio.load(); 116 | audio.addEventListener('ended', () => this.stopLocalSound()); 117 | audio.play(); 118 | } 119 | 120 | playInfix(infix: RandomInfix) { 121 | // Play random sound 122 | const matchingSounds = this.sounds()[0].filter( 123 | sound => sound.name.toLowerCase().includes(infix.infix) && sound.guildId === infix.guildId 124 | ); 125 | if (matchingSounds.length > 0) { 126 | this.playSound(sample(matchingSounds)); 127 | } else { 128 | this.snackBar.open('No matching sounds for this random button.'); 129 | } 130 | } 131 | 132 | playFirstMatch() { 133 | // Play the first search match 134 | const filteredSounds = this.filteredSounds(); 135 | if (filteredSounds.length > 0) { 136 | this.playSound(filteredSounds[0]); 137 | } 138 | } 139 | 140 | stopSound() { 141 | this.soundsService.stopSound(this.settings.guildId()).subscribe({ error: () => this.snackBar.open('Failed to stop playback.') }); 142 | } 143 | 144 | stopLocalSound() { 145 | const currentAudio = this.currentAudio(); 146 | if (currentAudio != null) { 147 | currentAudio.removeAllListeners(); 148 | currentAudio.pause(); 149 | currentAudio.remove(); 150 | this.currentAudio.set(null); 151 | this.currentLocalSound.set(null); 152 | } 153 | } 154 | 155 | joinChannel() { 156 | this.apiService.joinCurrentChannel(this.settings.guildId()).subscribe({ 157 | next: () => this.snackBar.open('Joined channel!', undefined, { duration: 2000 }), 158 | error: (error: HttpErrorResponse) => { 159 | if (error.status === 400) { 160 | this.snackBar.open('Failed to join you. Are you in a voice channel that is visible to the bot?'); 161 | } else { 162 | this.snackBar.open('Unknown error joining the voice channel.'); 163 | } 164 | }, 165 | }); 166 | } 167 | 168 | leaveChannel() { 169 | this.apiService.leaveChannel(this.settings.guildId()).subscribe({ 170 | next: () => this.snackBar.open('Left channel!', undefined, { duration: 2000 }), 171 | error: (error: HttpErrorResponse) => { 172 | if (error.status === 503) { 173 | this.snackBar.open('The bot is not in a voice channel.'); 174 | } else { 175 | this.snackBar.open('Unknown error leaving the voice channel.'); 176 | } 177 | }, 178 | }); 179 | } 180 | 181 | trackById(_position: number, item: Sound) { 182 | return item?.id; 183 | } 184 | 185 | openEventLog() { 186 | this.dialog.open(EventLogDialogComponent, { data: this.events$() }); 187 | } 188 | } 189 | --------------------------------------------------------------------------------