Compare commits

..

137 Commits

Author SHA1 Message Date
neru 20e162dc32 chore: remove unused param 2026-05-06 01:00:36 -03:00
neru 09e10e4113 fix: make v3 default 2026-02-13 22:48:32 -03:00
neru cbb5a9a76a fix: uncomment ShortName 2026-02-11 02:39:09 -03:00
neru 9025831f3d feat: use refreshtoken instead of api token 2026-02-11 02:37:02 -03:00
neru 06926e5601 style: run format:apply 2026-02-11 02:36:44 -03:00
neru 85c35021b5 style: run format:apply 2026-02-11 02:36:38 -03:00
neru c44f92f777 fix: dont use any 2026-02-11 02:36:33 -03:00
neru 1927728b60 fix: remove spammy log msg 2026-02-11 02:29:17 -03:00
neru 99b06b574b feat: refactor everything, handle websocket close 2026-02-09 04:49:59 -03:00
neru 7c3a5f6b56 fix: discard lib, implement azure tts 2026-02-08 02:27:02 -03:00
neru 27a6807340 fix: actually add mimic 😢 2026-02-06 14:12:22 -03:00
neru 2fe0551dee style: run format:apply and misc lint changes 2026-02-06 14:05:24 -03:00
neru 91a4c6e40d feat: add mimic 2026-02-06 14:04:57 -03:00
neru 69ee765889 feat: add bot category 2026-02-06 14:03:21 -03:00
neru d972e6598e fix: add missing env var 2026-02-02 09:35:47 -03:00
neru 2f4e944df4 fix: use env vars 2026-02-02 09:12:04 -03:00
neru 9571e32e61 feat: add script to dump firebase auth 2026-01-29 01:04:26 -03:00
neru b246afdc7f feat: add ElevenLabs Firebase token emulation 2026-01-29 01:04:19 -03:00
neru 123ed75b60 chore: add TTS_ELEVENLABS_TOKEN to dockerfile 2026-01-28 01:35:05 -03:00
neru 51ebb6c92d fix: implement tts_elevenlabs_token 2026-01-28 01:34:55 -03:00
neru 8e7a71164d fix: misc checks / changes 2026-01-28 01:34:32 -03:00
neru 224d1339e9 feat: add tts_elevenlabs_token 2026-01-28 01:33:59 -03:00
neru d9c623ac5c fix: getModels should return name not id 2026-01-23 22:36:25 -03:00
neru 68e622d318 fix: update docker-compose default vars 2026-01-23 14:07:15 -03:00
neru fea589dc2c fix: remove unused var 2026-01-23 14:06:13 -03:00
neru feabc732cf style: run lint and format 2026-01-23 14:03:45 -03:00
neru bfc749a034 fix: remove unneeded cast 2026-01-23 14:03:15 -03:00
neru 11539d149b fix: misc style / variable consistency changes 2026-01-23 14:03:02 -03:00
neru 7cbb5f3a9f feat: add ElevenLabs 2026-01-23 13:52:12 -03:00
neru f218a2cef9 style: run format:apply 2026-01-19 01:29:40 -03:00
neru 0cda7dd110 fix: disable generateSilence (unused) 2026-01-19 01:29:30 -03:00
neru ce98f13efd fix: remove unneeded call 2026-01-19 01:29:20 -03:00
neru 697cfd1de1 feat: check if msg is empty after filtering 2026-01-19 00:58:10 -03:00
neru f282a77411 feat: filter emotes, mentions and channels 2026-01-19 00:56:00 -03:00
neru 049897fb07 fix: undo changes 2026-01-17 20:54:30 -03:00
neru 17df430122 fix: ignore empty buffers 2026-01-17 20:53:11 -03:00
neru 042fde30c4 fix: refactor AudioMixer logic 2026-01-17 20:48:10 -03:00
neru c7ff5d3659 style: run format:apply 2026-01-15 19:26:56 -03:00
neru 893511ee11 fix: add fixed ms instead of percentage 2026-01-15 19:20:37 -03:00
neru 5bc4cd02ec feat: flush queue on tts-clear 2026-01-15 15:05:23 -03:00
neru 30966ec81a feat: add flush and stop methods 2026-01-15 15:05:16 -03:00
neru f7558913ee doc: important readme update 2026-01-15 14:40:01 -03:00
neru 6d21c3deca style: run format:apply 2026-01-15 14:36:33 -03:00
neru 14194d07ff fix: update default docker-compose 2026-01-15 14:14:57 -03:00
neru b3109d643d fix: add extra margin to avoid audios cutting off early 2026-01-15 13:14:30 -03:00
neru 00e02b9f97 fix: make database persistent 2026-01-15 13:00:31 -03:00
neru fd75f692d5 fix: cleanup streams on disconnection 2026-01-15 12:50:30 -03:00
neru e1363de9df fix: wrong check 2026-01-15 00:17:56 -03:00
neru 0fc38828be feat: add tiktok tts 2026-01-15 00:15:14 -03:00
neru c849c8ee11 feat: add owner_id bypass for debugging 2026-01-14 23:09:32 -03:00
neru 426c97e654 fix: add requiresAdmin to tts-channel 2026-01-14 23:08:47 -03:00
neru 60b66027a3 fix: add ffmpeg to docker image 2026-01-14 22:58:08 -03:00
neru fda4bd91aa style: fmt comment 2026-01-14 22:58:02 -03:00
neru 7b4dfb0dce fix: log transcoder errs 2026-01-14 22:57:55 -03:00
neru 8efdf0bc5b style: run format:apply 2026-01-14 22:52:53 -03:00
neru 449c4efbb7 fix: mod import validation 2026-01-14 22:52:02 -03:00
neru 4932bd18d3 chore: ignore docker-compose.yml 2026-01-14 22:40:29 -03:00
neru 726fd914e4 feat: add dockerfile 2026-01-14 22:37:25 -03:00
neru 753405c504 feat: add docker-compose.yml 2026-01-14 22:37:09 -03:00
neru 294e256feb style: run format:apply 2026-01-14 22:36:07 -03:00
neru c005bc0e54 fix: wrong casing 2026-01-14 22:10:24 -03:00
neru 8e8d5dc479 feat: set default voice if found 2026-01-14 21:51:20 -03:00
neru dfb58318af feat: check if mod can be used before listing it 2026-01-14 21:51:13 -03:00
neru 4abc2ff594 fix: make canBeUsed non async 2026-01-14 21:50:54 -03:00
neru 5877644ed9 feat: add default voices 2026-01-14 21:29:07 -03:00
neru 71da6e841d feat: add better checks before joining 2026-01-14 21:26:48 -03:00
neru c1d355993e chore: add polly 2026-01-14 20:39:25 -03:00
neru a401fdab15 feat: add (untested) polly module 2026-01-14 20:38:58 -03:00
neru c5e6395b89 feat: add canBeUsed to TTSModule 2026-01-14 20:38:10 -03:00
neru 0c394bdcbe style: change fn def type 2026-01-14 20:37:33 -03:00
neru efa52dffbc fix: implement clear cmd 2026-01-14 02:32:17 -03:00
neru 3354289207 feat: add ping command 2026-01-14 02:32:07 -03:00
neru 4fbf308650 feat: add commands command 2026-01-14 02:32:03 -03:00
neru 4033b6f6b5 feat: add general cat 2026-01-14 02:31:56 -03:00
neru d3a6decef6 fix: export CommandCategory 2026-01-14 02:31:51 -03:00
neru 4718e68c78 feat: add getCommands and getCategories 2026-01-14 02:31:44 -03:00
neru 7c282105d3 style: run format:apply 2026-01-14 01:32:45 -03:00
neru bff1bf3856 style: change name def 2026-01-13 23:47:24 -03:00
neru f87171590e feat: add azure TTS 2026-01-13 22:03:10 -03:00
neru c96ba7e63d fix instantiate TTSManager immediately 2026-01-13 21:50:47 -03:00
neru 21c69329ee style: formatting 2026-01-13 20:05:08 -03:00
neru 8e98b38fa8 style: refactor logic 2026-01-13 18:40:15 -03:00
neru 07da7538ba feat: add tts commands 2026-01-13 18:37:21 -03:00
neru 889b86b019 feat: add key methods 2026-01-13 18:37:11 -03:00
neru cfae674cb4 feat: implement guild_keys and user_keys 2026-01-13 18:37:06 -03:00
neru 7d0b5dc459 feat: implement db 2026-01-13 18:36:54 -03:00
neru af7c25e6ec style: run format:apply and cleanup unused imports 2026-01-13 18:36:40 -03:00
neru 9b4d1cbe24 feat: add (temp?) db mgr 2026-01-13 18:35:40 -03:00
neru aba00e3599 chore: add db.sqlite to ignore 2026-01-13 18:18:31 -03:00
neru 5f1a32dcb3 chore: add sequelize, sqlite3 2026-01-13 18:15:34 -03:00
neru 2ec7212cd3 style: run format:apply 2026-01-13 17:53:31 -03:00
neru 83569a27e7 style: run format:apply 2026-01-13 17:50:33 -03:00
neru 02949e8b16 style: remove unused variable 2026-01-13 17:49:56 -03:00
neru 645d58ca21 chore: re-add prettier 2026-01-13 17:49:10 -03:00
neru 905713a08d feat: add audio streams, queue and manager 2026-01-13 17:49:01 -03:00
neru b4cb95793b feat: add empty tts module 2026-01-13 17:48:48 -03:00
neru 0e171894d5 feat: add Google TTS module 2026-01-13 17:48:42 -03:00
neru 40487d9634 feat: add TTS module 2026-01-13 17:48:33 -03:00
neru 827af77895 chore: add node-audio-mixer and prism-media 2026-01-13 17:48:21 -03:00
neru e3ad17d25e feat: add empty vc listener 2026-01-10 12:22:18 -03:00
neru ace276b2a8 feat: add join and leave commands 2026-01-10 12:22:12 -03:00
neru c9c88baa11 chore: install davey 2026-01-10 12:19:26 -03:00
neru 3e0654047d fix: ignore /dist for eslint 2026-01-10 12:17:51 -03:00
neru ae6296e0ae chore: add voice support packages 2026-01-10 12:08:19 -03:00
neru 20540f7b53 fix: make requiresAdmin optional 2026-01-10 12:07:04 -03:00
neru 207b16e1cd style: run format:apply 2026-01-10 12:05:24 -03:00
neru f69a62a314 feat: add isModule 2026-01-10 12:05:20 -03:00
neru 6ad0ba5340 fix: wrong file handling for builds 2026-01-10 11:13:02 -03:00
neru 9e2e35f595 chore: idk how to make readmes 2026-01-10 10:46:13 -03:00
neru 4729977f93 chore: very important advanced markdown code 2026-01-10 10:45:24 -03:00
neru 1213f4fadf chore: important grammar change 2026-01-10 10:44:29 -03:00
neru 4a2524aedc chore: add readme 2026-01-10 10:43:31 -03:00
neru f424336eba feat: handle and dispatch commands 2026-01-10 10:26:35 -03:00
neru efe2cb7458 fix: load token from config mgr instead of storing 2026-01-10 10:11:31 -03:00
neru eb40c4d736 feat: use cmd manager 2026-01-10 10:11:18 -03:00
neru 5d29e531e5 style: move event listeners to bottom 2026-01-10 10:11:01 -03:00
neru 8e3f2c3904 feat: add command mgr 2026-01-10 10:10:41 -03:00
neru fd16528c07 fix: use rimraf for clear cmd 2026-01-10 10:10:34 -03:00
neru 7bb7d6abdb fix: define include 2026-01-10 09:46:00 -03:00
neru edaf360e2e fix: exclude eslint 2026-01-10 09:44:44 -03:00
neru d975547efe style: remove comments 2026-01-10 09:44:02 -03:00
neru f0e8f5e939 style: run format:apply 2026-01-10 09:27:41 -03:00
neru 710de16af1 feat: add registerable events 2026-01-10 09:26:40 -03:00
neru 88305e5c8b feat: add extra event listeners 2026-01-10 09:19:27 -03:00
neru 33adc4b22a feat: add index.ts 2026-01-10 09:17:43 -03:00
neru dcd747e9b3 feat: create basic bot module 2026-01-10 09:17:39 -03:00
neru 71e3dc9083 fix: only lint at /src 2026-01-10 09:10:02 -03:00
neru a4c70f5b09 feat: add config 2026-01-10 09:08:56 -03:00
neru bd66e7aa70 style: run format:apply 2026-01-10 09:08:24 -03:00
neru aed1ee515a feat: populate script list 2026-01-10 09:08:14 -03:00
neru 1a3bc54bcb feat: add .prettierrc 2026-01-10 09:07:52 -03:00
neru 24f121f1ad chore: add/configure eslint 2026-01-10 09:07:15 -03:00
neru 610611e1d9 chore: update package-lock 2026-01-10 09:05:37 -03:00
neru 857ae3e01a fix: add missing dotenv dep 2026-01-10 09:05:32 -03:00
neru 8de32af061 chore: add prettier 2026-01-10 09:05:24 -03:00
neru 86dea5b14c chore: ignore .env file 2026-01-10 08:51:30 -03:00
neru 706123ff44 chore: add discord.js 2026-01-10 08:50:03 -03:00
44 changed files with 8354 additions and 93 deletions
+6 -1
View File
@@ -1,2 +1,7 @@
node_modules/*
dist/*
dist/*
db.sqlite
.env
docker-compose.yml
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"arrowParens": "always",
"trailingComma": "none",
"bracketSpacing": true,
"useTabs": true
}
+10
View File
@@ -0,0 +1,10 @@
<h1 align="center">
    <b>⛧ Luma ⛧</b>
</h1>
<p align="center">
another discord bot, but this one runs a lil bit better, maybe, hopefully
</p>
<p>idk look at cute this seal tho</p>
<img src="https://i.pinimg.com/474x/16/eb/b9/16ebb902c9425b0d5a6251bbab048387.jpg"/>
View File
+14
View File
@@ -0,0 +1,14 @@
services:
bot:
build: .
container_name: luma
environment:
NODE_ENV: production
DISCORD_ID: ${DISCORD_ID}
DISCORD_TOKEN: ${DISCORD_TOKEN}
DISCORD_OWNER_ID: ${DISCORD_OWNER_ID}
TTS_TIKTOK_SESSIONID: ${TTS_TIKTOK_SESSIONID}
TTS_ELEVENLABS_REFRESHTOKEN: ${TTS_ELEVENLABS_REFRESHTOKEN}
restart: unless-stopped
volumes:
- ./db.sqlite:/app/db.sqlite
+18
View File
@@ -0,0 +1,18 @@
# build
FROM node:24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++ gcc
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# prod
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache python3 make g++ ffmpeg
COPY package*.json ./
RUN npm ci --only=production # only prod deps
COPY --from=builder /app/dist ./dist
CMD ["npm", "start"]
+9
View File
@@ -0,0 +1,9 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["src/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended, { ignores: ["dist/**", "**/dist/**"] }
]);
+5398 -4
View File
File diff suppressed because it is too large Load Diff
+28 -4
View File
@@ -4,8 +4,15 @@
"description": "",
"main": "index.js",
"scripts": {
"build": "npx tsc",
"start": "node dist/index.js"
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"watch": "tsc --watch",
"clean": "rimraf dist",
"build:clean": "npm run clean && npm run build",
"lint": "eslint",
"format": "prettier --check './src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc",
"format:apply": "prettier --write './src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc"
},
"repository": {
"type": "git",
@@ -16,11 +23,28 @@
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.5",
"eslint": "^9.39.2",
"globals": "^17.0.0",
"jiti": "^2.6.1",
"rimraf": "^6.1.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.52.0"
},
"dependencies": {
"colorts": "^0.1.63"
"@aws-sdk/client-polly": "^3.968.0",
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.0",
"@snazzah/davey": "^0.1.9",
"colorts": "^0.1.63",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"node-audio-mixer": "^2.1.0",
"prettier": "^3.7.4",
"prism-media": "^1.3.5",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7"
}
}
+10
View File
@@ -0,0 +1,10 @@
console.log('scanning localstorage')
const keys = Object.keys(localStorage).filter(k => k.startsWith("firebase:authUser"));
if (keys.length > 0) {
const data = JSON.parse(localStorage.getItem(keys[0]));
console.log("found in localstorage:", data.stsTokenManager);
} else {
console.error("no session found");
}
+223
View File
@@ -0,0 +1,223 @@
import {
CacheType,
Client,
GatewayIntentBits,
Interaction,
Message,
OAuth2Scopes,
PermissionFlagsBits,
VoiceState
} from 'discord.js';
import { Logger } from './utils/log';
import { config } from './utils/config';
import { Command, CommandCategory, CommandManager } from './commands';
import { DatabaseManager } from './modules/db';
type BotEventListeners = {
messageCreate: (message: Message) => void;
interactionCreate: (interaction: Interaction) => void;
voiceStateUpdate: (oldState: VoiceState, newState: VoiceState) => void;
};
type BotEventListener<K extends keyof BotEventListeners> = BotEventListeners[K];
export class Bot {
private client: Client | undefined;
private cmdMgr: CommandManager;
private readonly log: Logger;
private eventListeners: {
[K in keyof BotEventListeners]?: BotEventListener<K>[];
} = {};
/*
class methods
*/
private constructor() {
this.log = new Logger('Bot');
this.cmdMgr = new CommandManager('./commands');
}
public async init(): Promise<void> {
this.log.info('Bot init');
if (this.client)
throw new Error('Client already exists, was init called twice?');
this.log.info('Loading commands');
await this.cmdMgr.init();
this.log.info('Configuring database');
DatabaseManager.get.init(
this.cmdMgr.getUserKeys(),
this.cmdMgr.getGuildKeys()
);
this.log.info('Instantiating client');
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent
]
});
this.log.info('Registering callbacks');
this.client.on('clientReady', () => this.onReady());
this.client.on('error', (err) => this.onError(err, false));
this.client.on('shardError', (err) => this.onError(err, true));
this.client.on('messageCreate', (message: Message<boolean>) =>
this.onMessage(message)
);
this.client.on('interactionCreate', (interaction: Interaction<CacheType>) =>
this.onInteraction(interaction)
);
this.client.on(
'voiceStateUpdate',
(oldState: VoiceState, newState: VoiceState) =>
this.onVoiceStateUpdate(oldState, newState)
);
this.log.info('Logging in...');
await this.client.login(config.token);
}
public getCommands(): Array<Command> {
return this.cmdMgr.getAll();
}
public getCategories(): Array<CommandCategory> {
return this.cmdMgr.getCategories();
}
/*
event listeners
*/
private onReady(): void {
this.log.info('Logged in');
const inviteLink = this.client?.generateInvite({
scopes: [OAuth2Scopes.ApplicationsCommands, OAuth2Scopes.Bot],
permissions: [
PermissionFlagsBits.AddReactions,
PermissionFlagsBits.AttachFiles,
PermissionFlagsBits.ChangeNickname,
PermissionFlagsBits.Connect,
PermissionFlagsBits.Speak,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.SendMessagesInThreads,
PermissionFlagsBits.SendVoiceMessages,
PermissionFlagsBits.EmbedLinks
]
});
this.log.info('Invite link: %s', inviteLink);
}
private onError(error: Error, isShardError: boolean): void {
if (isShardError)
this.log.error(
'A shard error ocurred: %s - %s - %s',
error.name,
error.message,
error.stack
);
else
this.log.error(
'An error ocurred: %s - %s - %s',
error.name,
error.message,
error.stack
);
}
/*
public event listeners
*/
private async onInteraction(interaction: Interaction): Promise<void> {
this.emit('interactionCreate', interaction);
}
private async onMessage(message: Message): Promise<void> {
this.emit('messageCreate', message);
}
private async onVoiceStateUpdate(
oldState: VoiceState,
newState: VoiceState
): Promise<void> {
this.emit('voiceStateUpdate', oldState, newState);
}
/*
registerable event system
*/
public on<K extends keyof BotEventListeners>(
event: K,
listener: BotEventListener<K>
): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = [];
}
(this.eventListeners[event] as BotEventListener<K>[]).push(listener);
}
public off<K extends keyof BotEventListeners>(
event: K,
listener: BotEventListener<K>
): boolean {
const listeners = this.eventListeners[event];
if (!listeners) return false;
const index = (listeners as BotEventListener<K>[]).indexOf(listener);
if (index > -1) {
(listeners as BotEventListener<K>[]).splice(index, 1);
return true;
}
return false;
}
public once<K extends keyof BotEventListeners>(
event: K,
listener: BotEventListener<K>
): void {
const onceWrapper = ((...args: Parameters<BotEventListener<K>>) => {
this.off(event, onceWrapper as BotEventListener<K>);
(listener as (...args: unknown[]) => void)(...args);
}) as BotEventListener<K>;
this.on(event, onceWrapper);
}
private emit<K extends keyof BotEventListeners>(
event: K,
...args: Parameters<BotEventListener<K>>
): void {
const listeners = this.eventListeners[event];
if (listeners) {
for (const listener of listeners as BotEventListener<K>[]) {
try {
(listener as (...args: unknown[]) => void)(...args);
} catch (error) {
this.log.error(
`Error in event listener for ${String(event)}:`,
error
);
}
}
}
}
/*
singleton logic
*/
static #instance: Bot | null = null;
public static get get(): Bot {
if (!Bot.#instance) Bot.#instance = new Bot();
return Bot.#instance;
}
}
+352
View File
@@ -0,0 +1,352 @@
import fs from 'fs';
import path from 'path';
import {
AutocompleteInteraction,
BaseInteraction,
ChatInputCommandInteraction,
GuildMember,
Interaction,
Message,
MessageFlags,
PermissionFlagsBits,
REST,
Routes,
SlashCommandOptionsOnlyBuilder,
VoiceState
} from 'discord.js';
import { Logger } from './utils/log';
import { config } from './utils/config';
import { Bot } from './bot';
import { isModule } from './utils/misc';
export interface Command {
name?: string;
builder?: SlashCommandOptionsOnlyBuilder;
requiresAdmin?: boolean;
ownerOnly?: boolean;
guild_keys?: Record<string, unknown>;
user_keys?: Record<string, unknown>;
execute?: (interaction: ChatInputCommandInteraction) => Promise<void>;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void>;
messageListener?: (msg: Message) => Promise<void>;
voiceStateListener?: (
prevState: VoiceState,
newState: VoiceState
) => Promise<void>;
}
export interface CommandCategoryInfo {
name: string;
description: string;
}
export interface CommandCategory {
info: CommandCategoryInfo;
commands: Command[];
}
export class CommandManager {
private readonly folderPath: string;
private readonly log: Logger;
private initialized: boolean = false;
private categories: Array<CommandCategory> = [];
/*
public
*/
public constructor(cmdFolder: string) {
this.folderPath = path.resolve(__dirname, cmdFolder);
this.log = new Logger('Command manager');
}
public async init(): Promise<void> {
if (this.initialized)
throw new Error('CommandManager was already initialized');
this.initialized = true;
await this.populateCommands();
this.registerSlashCommands();
const bot = Bot.get;
bot.on('interactionCreate', (interaction: Interaction) => {
this.onInteraction(interaction);
});
bot.on('messageCreate', (message: Message) => {
this.onMessage(message);
});
bot.on('voiceStateUpdate', (oldState: VoiceState, newState: VoiceState) => {
this.onVoiceStateUpdate(oldState, newState);
});
}
public getCategories(): Array<CommandCategory> {
return this.categories;
}
public get(commandName: string): Command | null {
return this.getAll().find((cmd) => cmd.name === commandName) || null;
}
public getAll(): Command[] {
return this.categories.flatMap((cat) => cat.commands);
}
public getAllInteractable(): Command[] {
return this.getAll().filter((cmd) => !!cmd.name);
}
public getGuildKeys(): Record<string, unknown> {
return this.mergeKeys('guild_keys');
}
public getUserKeys(): Record<string, unknown> {
return this.mergeKeys('user_keys');
}
private mergeKeys(
keyType: 'guild_keys' | 'user_keys'
): Record<string, unknown> {
return this.getAll()
.flatMap((cmd) => (cmd[keyType] ? [cmd[keyType]] : []))
.reduce((acc, val) => Object.assign(acc, val), {});
}
/*
internal
*/
private async populateCommands() {
if (!fs.existsSync(this.folderPath))
throw new Error(`Command directory not found: ${this.folderPath}`);
const categoryFolders = fs.readdirSync(this.folderPath);
for (const categoryFolder of categoryFolders) {
const catPath = path.join(this.folderPath, categoryFolder);
await this.processCategory(catPath, categoryFolder);
}
}
private async processCategory(
catPath: string,
folderName: string
): Promise<void> {
try {
const stat = fs.statSync(catPath);
if (!stat.isDirectory()) {
this.log.warning(
'Skipping non-directory entry found on cmd folder (%s)',
folderName
);
return;
}
const catInfo = await this.loadCatInfo(catPath);
const commands = await this.loadCatCommands(catPath);
if (catInfo == undefined) {
this.log.warning('Folder %s was missing info, ignoring', folderName);
return;
}
this.categories.push({
info: catInfo,
commands: commands
});
} catch (err) {
this.log.error('Error processing category %s: %s', folderName, err);
}
}
private async loadCatInfo(
catPath: string
): Promise<CommandCategoryInfo | undefined> {
try {
const extensions = ['js', 'mjs', 'cjs', 'ts'];
for (const ext of extensions) {
const descriptorPath = path.join(catPath, `_category.${ext}`);
if (!fs.existsSync(descriptorPath)) continue;
const module = await import(`file://${descriptorPath}`);
return (
module.default?.default ||
module.default ||
(module as CommandCategoryInfo)
);
}
throw new Error('Missing categoryinfo.json');
} catch (err) {
this.log.error('Error loading category info at %s: %s', catPath, err);
return undefined;
}
}
private async loadCatCommands(catPath: string): Promise<Array<Command>> {
const promises = fs
.readdirSync(catPath)
.filter((file) => isModule(file))
.map(
async (file) => await this.attemptLoadCommand(path.join(catPath, file))
);
return (await Promise.all(promises)).filter((cmd): cmd is Command => !!cmd);
}
/*
cmd parsing
*/
private async attemptLoadCommand(filePath: string): Promise<Command | null> {
try {
const module = await import(`file://${filePath}`);
const command =
module.default?.default || module.default || (module as Command);
return command;
} catch (error) {
this.log.error(
`Error loading command ${path.basename(filePath)}: ${error}`
);
return null;
}
}
/*
misc functions
*/
private async registerSlashCommands(): Promise<void> {
this.log.info('Registering slash commands...');
try {
const cmdJSONList = this.getAll()
.filter((cmd) => cmd.builder !== undefined)
.map((cmd) => cmd.builder?.toJSON())
.filter((cmd) => cmd !== undefined);
const rest = new REST({ version: '10' }).setToken(config.token as string);
await rest.put(Routes.applicationCommands(config.client_id), {
body: cmdJSONList
});
this.log.info('Registered %i commands', cmdJSONList.length);
} catch (err) {
this.log.warning(
'Error occurred while registering slash commands: %s',
err
);
}
}
/*
interaction handling
*/
private async executeCommandInteraction(
interaction: ChatInputCommandInteraction
): Promise<void> {
const cmdName = interaction.commandName;
const command = this.get(cmdName);
if (!command)
return this.log.error(
'Attempted to execute non-existing command (%s)',
cmdName
);
if (command.requiresAdmin) {
const member = interaction.member as GuildMember;
if (
!member.permissions.has(PermissionFlagsBits.Administrator) &&
member.id != config.owner_id
) {
await interaction.reply({
content:
"You don't have the permissions required to execute this command.",
flags: MessageFlags.Ephemeral
});
return;
}
}
if (command.ownerOnly) {
const member = interaction.member as GuildMember;
if (member.id != config.owner_id) {
await interaction.reply({
content: 'This command is restricted.',
flags: MessageFlags.Ephemeral
});
return;
}
}
if (command.execute) {
try {
await command.execute(interaction);
} catch (error) {
this.log.error(
'Error occurred while executing command %s: %s',
cmdName,
error
);
}
} else this.log.error('Command is missing execute method: %s', cmdName);
}
private async autocompleteCommandInteraction(
interaction: AutocompleteInteraction
): Promise<void> {
const cmdName = interaction.commandName;
const command = this.get(cmdName);
if (!command)
return this.log.error(
'Attempted to execute unexisting command (%s)',
cmdName
);
if (command.autocomplete) {
try {
await command.autocomplete(interaction);
} catch (error) {
this.log.error(
'Error occurred while autocompleting command %s: %s',
cmdName,
error
);
}
} else
this.log.error('Command is missing autocomplete method: %s', cmdName);
}
/*
event listeners
*/
private async onInteraction(interaction: BaseInteraction): Promise<void> {
/*
cmd execution
*/
if (interaction.isChatInputCommand())
return this.executeCommandInteraction(interaction);
if (interaction.isAutocomplete())
return this.autocompleteCommandInteraction(interaction);
return;
}
private async onMessage(message: Message<boolean>): Promise<void> {
if (message.author.bot) return;
for (const cmd of this.getAll())
if (cmd.messageListener) await cmd.messageListener(message);
}
private async onVoiceStateUpdate(
oldState: VoiceState,
newState: VoiceState
): Promise<void> {
for (const cmd of this.getAll())
if (cmd.voiceStateListener)
await cmd.voiceStateListener(oldState, newState);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { CommandCategoryInfo } from '../../commands';
const info: CommandCategoryInfo = {
name: 'Bot',
description: 'Bot management commands'
};
export default info;
+100
View File
@@ -0,0 +1,100 @@
import {
ChatInputCommandInteraction,
MessageCreateOptions,
MessageFlags,
SlashCommandBuilder,
TextChannel
} from 'discord.js';
import { Command } from '../../commands';
const builder = new SlashCommandBuilder()
.setName('bot-mimic')
.setDescription('Makes the bot send a message')
.addStringOption((opt) =>
opt
.setName('content')
.setDescription('The text content of the message')
.setRequired(false)
)
.addAttachmentOption((opt) =>
opt
.setName('attachment')
.setDescription('An attachment for the message')
.setRequired(false)
)
.addStringOption((opt) =>
opt
.setName('reply')
.setDescription('The message ID that the bot should reply to')
.setRequired(false)
);
const command: Command = {
name: 'bot-mimic',
builder: builder,
ownerOnly: true,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (!interaction.channel?.isTextBased()) {
await interaction.editReply(
'This command can only be used in a text channel.'
);
return;
}
if (!interaction.channel.isSendable()) {
await interaction.editReply('Channel is not sendable');
return;
}
const content = interaction.options.getString('content');
const attachment = interaction.options.getAttachment('attachment');
const replyId = interaction.options.getString('reply');
if (!content && !attachment) {
await interaction.editReply(
'Unable to send empty message. Specify content or attachment, or both.'
);
return;
}
const channel = interaction.channel as TextChannel;
const message: MessageCreateOptions = {};
if (content) {
message.content = content;
}
if (replyId) {
try {
const replyMessage = await channel.messages.fetch(replyId);
message.reply = {
messageReference: replyMessage.id
};
} catch {
await interaction.editReply('Invalid message ID for reply.');
return;
}
}
if (attachment) {
message.files = [
{
attachment: attachment.proxyURL,
name: attachment.name
}
];
}
try {
await channel.send(message);
await interaction.editReply('Message sent successfully.');
} catch (error) {
console.error('Failed to send message:', error);
await interaction.editReply('Failed to send message.');
}
}
};
export default command;
+8
View File
@@ -0,0 +1,8 @@
import { CommandCategoryInfo } from '../../commands';
const info: CommandCategoryInfo = {
name: 'General',
description: 'General / uncategorized commands'
};
export default info;
+37
View File
@@ -0,0 +1,37 @@
import {
ChatInputCommandInteraction,
EmbedBuilder,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { Bot } from '../../bot';
const builder = new SlashCommandBuilder()
.setName('commands')
.setDescription('Shows a list of all the commands.');
const cmd: Command = {
name: builder.name,
builder: builder,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
const responseEmbed = new EmbedBuilder()
.setColor('Blurple')
.setTitle('Command List');
const bot = Bot.get;
bot.getCategories().forEach(({ info, commands }) => {
const fieldBody = commands
.filter(({ builder }) => builder)
.map(
({ builder }) => `• **${builder?.name}** - ${builder?.description}`
)
.join('\n');
responseEmbed.addFields({ name: info.name, value: fieldBody });
});
await interaction.reply({ embeds: [responseEmbed] });
}
};
export default cmd;
+16
View File
@@ -0,0 +1,16 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { Command } from '../../commands';
const builder = new SlashCommandBuilder()
.setName('ping')
.setDescription('Pong.');
const cmd: Command = {
name: builder.name,
builder: builder,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
interaction.reply('pong!');
}
};
export default cmd;
+8
View File
@@ -0,0 +1,8 @@
import { CommandCategoryInfo } from '../../commands';
const info: CommandCategoryInfo = {
name: 'TTS',
description: 'Text to Speech related commands'
};
export default info;
+33
View File
@@ -0,0 +1,33 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { Command } from '../../commands';
import { getVoiceConnection, VoiceConnectionStatus } from '@discordjs/voice';
import { AudioStreamManager } from '../../modules/audioStreams';
const builder = new SlashCommandBuilder()
.setName('tts-clear')
.setDescription('Clears the TTS queue');
const cmd: Command = {
name: builder.name,
builder: builder,
requiresAdmin: true,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
if (!interaction.guild) {
interaction.reply('This command only works on Guilds');
return;
}
const voiceConnection = getVoiceConnection(interaction.guild.id);
if (voiceConnection?.state.status !== VoiceConnectionStatus.Ready) return;
const stream = AudioStreamManager.get.getOrCreateStream(voiceConnection);
const queue = stream.getQueue('TTS');
queue.clear();
queue.flush();
interaction.reply('Queue cleared.');
}
};
export default cmd;
+118
View File
@@ -0,0 +1,118 @@
import {
AutocompleteInteraction,
ChatInputCommandInteraction,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { TTSManager } from '../../modules/tts';
import { ElevenLabsTTS } from '../../modules/tts-modes/elevenlabs';
const builder = new SlashCommandBuilder()
.setName('elevenlabs-settings')
.setDescription('Configures ElevenLabs generation')
.addNumberOption((opt) =>
opt
.setName('stability')
.setDescription('Determines whether to be stable or more variable')
.setMaxValue(1)
.setMinValue(0)
)
.addNumberOption((opt) =>
opt
.setName('similarity-boost')
.setDescription('Boosts clarity and target voice similarity')
.setMaxValue(1.0)
.setMinValue(0)
)
.addNumberOption((opt) =>
opt
.setName('style')
.setDescription('How much should the style be exaggerated')
.setMaxValue(1.0)
.setMinValue(0)
)
.addNumberOption((opt) =>
opt
.setName('speed')
.setDescription('The speed at which the text should be read')
.setMaxValue(1.2)
.setMinValue(0.7)
)
.addBooleanOption((opt) =>
opt
.setName('speaker-boost')
.setDescription('Should speaker boost be enabled?')
)
.addStringOption((opt) =>
opt
.setName('model')
.setDescription('Which generation model to use')
.setAutocomplete(true)
);
const cmd: Command = {
name: builder.name,
builder: builder,
ownerOnly: true,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
const mod = TTSManager.get.getModule('ElevenLabs') as
| ElevenLabsTTS
| undefined;
if (!mod) return;
const stability =
interaction.options.getNumber('stability') ||
ElevenLabsTTS.DEFAULT_SETTINGS.stability;
const similarityBoost =
interaction.options.getNumber('similarity-boost') ||
ElevenLabsTTS.DEFAULT_SETTINGS.similarity_boost;
const style =
interaction.options.getNumber('style') ||
ElevenLabsTTS.DEFAULT_SETTINGS.style;
const speed =
interaction.options.getNumber('speed') ||
ElevenLabsTTS.DEFAULT_SETTINGS.speed;
const speakerBoost =
interaction.options.getBoolean('speaker-boost') ||
ElevenLabsTTS.DEFAULT_SETTINGS.user_speaker_boost;
mod.setSettings({
stability: stability,
style: style,
speed: speed,
user_speaker_boost: speakerBoost,
similarity_boost: similarityBoost
});
const model = interaction.options.getString('model');
if (model) mod.setModel(model);
interaction.reply('ElevenLabs settings applied');
},
autocomplete: async (interaction: AutocompleteInteraction): Promise<void> => {
const focused = interaction.options.getFocused(true);
if (focused.name != 'model') return;
const mod = TTSManager.get.getModule('ElevenLabs') as
| ElevenLabsTTS
| undefined;
if (!mod) return;
const models = await mod.getModels();
const filtered: string[] = models
.filter((model) =>
model.toLowerCase().startsWith(focused.value.toLowerCase())
)
.slice(0, 25);
await interaction.respond(
filtered.map((choice) => ({ name: choice, value: choice }))
);
}
};
export default cmd;
+104
View File
@@ -0,0 +1,104 @@
import { Message } from 'discord.js';
import { Command } from '../../commands';
import { Logger } from '../../utils/log';
import { getVoiceConnection, VoiceConnectionStatus } from '@discordjs/voice';
import { TTSManager } from '../../modules/tts';
import { AudioStreamManager } from '../../modules/audioStreams';
import { Readable } from 'stream';
import { DataTypes } from 'sequelize';
import { config } from '../../utils/config';
import { DatabaseManager } from '../../modules/db';
const URL_REGEX = /(?:https?|ftp):\/\/[\n\S]+/g;
const DISCORD_REGEX = /<(?::\w+:|@!*&*|#)[0-9]+>/g; // from: https://www.reddit.com/r/discordapp/comments/iibxms/if_anyone_needs_regex_to_match_an_emote_mention/
class TTSListener implements Command {
private log: Logger;
constructor() {
this.log = new Logger('TTS Listener');
}
user_keys = {
tts_voice: {
type: DataTypes.STRING,
defaultValue: config.tts_default_voice
},
tts_mode: {
type: DataTypes.STRING,
defaultValue: config.tts_default_mode
}
};
guild_keys = {
tts_channel: {
type: DataTypes.STRING
}
};
messageListener = async (msg: Message): Promise<void> => {
if (!msg.content || !msg.guild || !msg.member?.voice) return;
const voiceConnection = getVoiceConnection(msg.guildId!);
if (voiceConnection?.state.status !== VoiceConnectionStatus.Ready) return;
const guild = msg.guild;
const me = guild.members.me;
const member = msg.member;
if (!me) return;
if (member.voice.channelId !== me.voice.channelId) return;
const db = await DatabaseManager.get;
const guildData = await db.getGuild(guild.id);
if (msg.channelId !== guildData.get('tts_channel')) return;
const userData = await db.getUser(member.id);
const modName = userData.get('tts_mode') as string;
const voiceName = userData.get('tts_voice') as string;
const processTTS = async () => {
try {
const ttsModule = TTSManager.get.getModule(modName);
if (!ttsModule) return;
const voices = await ttsModule.getVoices();
if (!voices) return;
if (!voices.includes(voiceName)) return;
let msgFiltered = msg.content.replace(URL_REGEX, 'a link');
msgFiltered = msgFiltered.replace(DISCORD_REGEX, '');
if (msgFiltered.length === 0) return;
const audio = await ttsModule.generate(voiceName, msgFiltered);
if (!audio) {
this.log.error("TTS generation didn't return anything");
return;
}
if (audio.data) {
const stream =
AudioStreamManager.get.getOrCreateStream(voiceConnection);
const queue = stream.getQueue('TTS');
queue.enqueue(Readable.from(audio.data));
}
if (audio.error) {
this.log.error(
'Error occurred while generating message: (%s)',
audio.error
);
}
} catch (err) {
this.log.error('Error occurred while processing TTS message (%s)', err);
}
};
processTTS();
};
}
export default new TTSListener();
+34
View File
@@ -0,0 +1,34 @@
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { DatabaseManager } from '../../modules/db';
const builder = new SlashCommandBuilder()
.setName('tts-channel')
.setDescription('Sets the channel where TTS messages will be read from');
const cmd: Command = {
name: builder.name,
builder: builder,
requiresAdmin: true,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (!interaction.guild) {
interaction.editReply('This message can only be executed on guilds');
return;
}
const guildData = await DatabaseManager.get.getGuild(interaction.guild.id);
await guildData.set('tts_channel', interaction.channelId);
await guildData.save();
interaction.editReply('TTS channel updated.');
}
};
export default cmd;
+71
View File
@@ -0,0 +1,71 @@
import {
AutocompleteInteraction,
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { DatabaseManager } from '../../modules/db';
import { TTSManager } from '../../modules/tts';
const builder = new SlashCommandBuilder()
.setName('tts-mode')
.setDescription('Selects a mode to use for TTS')
.addStringOption((opt) =>
opt
.setName('mode')
.setDescription('Which TTS provider to use')
.setAutocomplete(true)
.setRequired(true)
);
const cmd: Command = {
name: builder.name,
builder: builder,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userData = await DatabaseManager.get.getUser(interaction.user.id);
const modeName = interaction.options.getString('mode', true);
const selectedMode = TTSManager.get
.getModules()
.filter(async (mod) => await mod.canBeUsed())
.find((mode) => mode.name === modeName);
if (!selectedMode) {
await interaction.editReply(`Unknown mode (${modeName})`);
return;
}
await userData.set('tts_mode', modeName);
if (selectedMode.defaultVoice)
await userData.set('tts_voice', selectedMode.defaultVoice);
await userData.save();
interaction.editReply(`TTS mode has been set to: ${modeName}.`);
},
autocomplete: async (interaction: AutocompleteInteraction): Promise<void> => {
const focused = interaction.options.getFocused(true);
if (focused.name != 'mode') return;
const modes = TTSManager.get.getModules();
const filtered: string[] = modes
.filter((mod) => mod.canBeUsed())
.filter((mod) => {
return mod.name
? mod.name.toLowerCase().startsWith(focused.value.toLowerCase())
: undefined;
})
.map((mod) => mod.name)
.slice(0, 25);
await interaction.respond(
filtered.map((choice) => ({ name: choice, value: choice }))
);
}
};
export default cmd;
+76
View File
@@ -0,0 +1,76 @@
import {
AutocompleteInteraction,
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { DatabaseManager } from '../../modules/db';
import { TTSManager } from '../../modules/tts';
const builder = new SlashCommandBuilder()
.setName('tts-voice')
.setDescription('Selects a voice to use for TTS')
.addStringOption((opt) =>
opt
.setName('voice')
.setDescription('Which voice to use')
.setAutocomplete(true)
.setRequired(true)
);
const cmd: Command = {
name: builder.name,
builder: builder,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userData = await DatabaseManager.get.getUser(interaction.user.id);
const mod = TTSManager.get.getModule(userData.get('tts_mode') as string);
if (!mod) return;
const voices = await mod.getVoices();
if (!voices) {
await interaction.editReply(
'Unknown error occured while fetching TTS voices'
);
return;
}
const voiceName = interaction.options.getString('voice', true);
if (!voices.includes(voiceName)) {
await interaction.editReply('Invalid voice');
return;
}
await userData.set('tts_voice', voiceName);
await userData.save();
interaction.editReply(`TTS voice has been set to: ${voiceName}.`);
},
autocomplete: async (interaction: AutocompleteInteraction): Promise<void> => {
const focused = interaction.options.getFocused(true);
if (focused.name != 'voice') return;
const userData = await DatabaseManager.get.getUser(interaction.user.id);
const mode = TTSManager.get.getModule(userData.get('tts_mode') as string);
if (!mode) return;
const voices: string[] | undefined = await mode.getVoices();
if (!voices) return;
const filtered: string[] = voices
.filter((voice) =>
voice.toLowerCase().startsWith(focused.value.toLowerCase())
)
.slice(0, 25);
await interaction.respond(
filtered.map((choice) => ({ name: choice, value: choice }))
);
}
};
export default cmd;
+8
View File
@@ -0,0 +1,8 @@
import { CommandCategoryInfo } from '../../commands';
const info: CommandCategoryInfo = {
name: 'Voice',
description: 'Voice chat related commands'
};
export default info;
+87
View File
@@ -0,0 +1,87 @@
import {
ChatInputCommandInteraction,
GuildMember,
PermissionsBitField,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import {
CreateVoiceConnectionOptions,
getVoiceConnection,
joinVoiceChannel,
JoinVoiceChannelOptions
} from '@discordjs/voice';
const builder = new SlashCommandBuilder()
.setName('join')
.setDescription('Makes the bot join your current voice channel');
const cmd: Command = {
name: builder.name,
builder: builder,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
const member = interaction.member as GuildMember;
if (!member || !interaction.guild) {
interaction.reply('This command only works on guilds');
return;
}
if (!member.voice.channel || !member.voice.channelId) {
interaction.reply('You are not currently on a voice channel');
return;
}
const me = interaction.guild.members.me as GuildMember;
if (
getVoiceConnection(interaction.guild.id) &&
me.voice.channelId === member.voice.channelId
) {
interaction.reply('Already connected');
return;
}
const voiceChannel = member.voice.channel;
if (
voiceChannel.userLimit != 0 &&
voiceChannel.members.size >= voiceChannel.userLimit
) {
interaction.reply('Channel is full');
return;
}
const perms = voiceChannel.permissionsFor(me);
if (!perms.has(PermissionsBitField.Flags.ViewChannel)) {
interaction.reply("I don't have permissions to see that channel");
return;
}
if (!perms.has(PermissionsBitField.Flags.Connect)) {
interaction.reply("I don't have the permissions to join that channel");
return;
}
if (!perms.has(PermissionsBitField.Flags.Speak)) {
interaction.reply("I don't have permissions to speak on that channel");
return;
}
const voiceOptions: JoinVoiceChannelOptions & CreateVoiceConnectionOptions =
{
channelId: member.voice.channelId,
guildId: interaction.guild.id,
adapterCreator: interaction.guild.voiceAdapterCreator
};
const connection = await joinVoiceChannel(voiceOptions);
if (!connection) {
interaction.reply('Unable to join');
return;
}
interaction.reply('Joined');
}
};
export default cmd;
+36
View File
@@ -0,0 +1,36 @@
import {
ChatInputCommandInteraction,
GuildMember,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { getVoiceConnection } from '@discordjs/voice';
const builder = new SlashCommandBuilder()
.setName('leave')
.setDescription('Makes the bot leave its current voice channel');
const cmd: Command = {
name: builder.name,
builder: builder,
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
const member = interaction.member as GuildMember;
if (!member || interaction.guild === null) {
interaction.reply('This command only works on guilds');
return;
}
const connection = getVoiceConnection(interaction.guildId as string);
if (!connection) {
interaction.reply('currently not connected to a voice channel');
return;
}
connection.disconnect();
connection.destroy();
interaction.reply('Disconnected');
}
};
export default cmd;
+25
View File
@@ -0,0 +1,25 @@
import { VoiceState } from 'discord.js';
import { Command } from '../../commands';
import { getVoiceConnection } from '@discordjs/voice';
const cmd: Command = {
voiceStateListener: async function (oldState: VoiceState): Promise<void> {
const guild = oldState.guild;
if (!guild) return;
const voiceConnection = getVoiceConnection(guild.id);
if (!voiceConnection) return;
const me = guild.members.me;
if (!me) return;
if (!me.voice.channel) return;
if (me.voice.channel.members.size > 1) return;
voiceConnection.disconnect();
voiceConnection.destroy();
}
};
export default cmd;
+5
View File
@@ -0,0 +1,5 @@
import 'dotenv/config';
import { Bot } from './bot';
Bot.get.init();
+236
View File
@@ -0,0 +1,236 @@
import {
AudioPlayer,
AudioPlayerStatus,
createAudioPlayer,
createAudioResource,
StreamType,
VoiceConnection,
VoiceConnectionStatus
} from '@discordjs/voice';
import { AudioMixer } from 'node-audio-mixer';
import { PassThrough, Readable } from 'stream';
import prism from 'prism-media';
const DURATION_EXTRA_MS = 1000;
export class StreamQueue {
private queue: Readable[] = [];
private isPlaying = false;
private mixer: MixedStream;
private currentStop: (() => void) | null = null;
constructor(mixer: MixedStream) {
this.mixer = mixer;
}
public enqueue(resource: Readable) {
this.queue.push(resource);
this.processQueue();
}
private async processQueue() {
if (this.isPlaying || this.queue.length === 0) return;
this.isPlaying = true;
const nextStream = this.queue.shift();
try {
if (nextStream) {
const { completion, stop } = this.mixer.playStream(nextStream);
this.currentStop = stop;
await completion;
this.currentStop = null;
}
} catch (e) {
console.error('Queue error:', e);
} finally {
this.isPlaying = false;
this.processQueue();
}
}
public clear() {
this.queue = [];
if (this.currentStop) {
this.currentStop();
this.currentStop = null;
}
}
public flush() {
this.mixer.flush();
}
}
export class MixedStream {
public readonly player: AudioPlayer;
private mixer: AudioMixer;
private output: PassThrough | undefined;
private queues: Map<string, StreamQueue> = new Map();
public constructor() {
this.player = createAudioPlayer();
this.mixer = new AudioMixer({
channels: 2,
bitDepth: 16,
sampleRate: 48000,
autoClose: false,
generateSilence: false
});
}
public getQueue(name: string): StreamQueue {
let queue = this.queues.get(name);
if (!queue) {
queue = new StreamQueue(this);
this.queues.set(name, queue);
}
return queue;
}
public playStream(source: Readable): {
completion: Promise<void>;
stop: () => void;
} {
let stopCallback: () => void = () => {};
const completion = new Promise<void>((resolve) => {
if (this.player.state.status === AudioPlayerStatus.Idle) {
this.setupPipeline();
}
const mixerInput = this.mixer.createAudioInput({
channels: 2,
sampleRate: 48000,
bitDepth: 16,
volume: 100
});
const transcoder = new prism.FFmpeg({
args: [
'-analyzeduration',
'0',
'-loglevel',
'0',
'-f',
's16le',
'-ar',
'48000',
'-ac',
'2'
]
});
let totalBytes = 0;
transcoder.on('data', (chunk: Buffer) => {
totalBytes += chunk.length;
});
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
source.unpipe(transcoder);
source.destroy();
transcoder.unpipe(mixerInput);
transcoder.destroy();
this.mixer.removeAudioinput(mixerInput);
mixerInput.destroy();
resolve();
};
stopCallback = cleanup;
transcoder.on('end', () => {
const durationMs = (totalBytes / 192000) * 1000 + DURATION_EXTRA_MS;
setTimeout(() => {
cleanup();
}, durationMs);
});
transcoder.on('error', (err) => {
console.error('Transcoder error:', err);
cleanup();
});
source.pipe(transcoder).pipe(mixerInput);
});
return { completion, stop: stopCallback };
}
public destroy(): void {
this.player.stop();
if (this.output) this.output.destroy();
this.mixer.destroy();
}
public flush(): void {
this.player.stop();
this.setupPipeline();
}
private setupPipeline(): void {
if (this.output) {
this.mixer.unpipe(this.output);
this.output.destroy();
}
this.output = new PassThrough({ highWaterMark: 1024 * 256 });
this.mixer.pipe(this.output);
const resource = createAudioResource(this.output, {
inputType: StreamType.Raw
});
this.player.play(resource);
}
}
export class AudioStreamManager {
private streams = new WeakMap<VoiceConnection, MixedStream>();
public getOrCreateStream(conn: VoiceConnection): MixedStream {
let stream = this.streams.get(conn);
if (stream) return stream;
stream = new MixedStream();
this.streams.set(conn, stream);
conn.subscribe(stream.player);
conn.on('stateChange', (_, newState) => {
if (
newState.status === VoiceConnectionStatus.Disconnected ||
newState.status === VoiceConnectionStatus.Destroyed
) {
this.destroyStream(conn);
}
});
return stream;
}
public destroyStream(conn: VoiceConnection): void {
const stream = this.streams.get(conn);
if (stream) {
stream.destroy();
this.streams.delete(conn);
}
}
/*
singleton logic
*/
static #instance: AudioStreamManager | null = null;
public static get get(): AudioStreamManager {
if (!AudioStreamManager.#instance)
AudioStreamManager.#instance = new AudioStreamManager();
return AudioStreamManager.#instance;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { DataTypes, Model, ModelStatic, Sequelize } from 'sequelize';
export class DatabaseManager {
private readonly db: Sequelize;
public guildData: ModelStatic<Model> | null = null;
public userData: ModelStatic<Model> | null = null;
private constructor() {
this.db = new Sequelize('db', 'luma', '', {
host: 'localhost',
dialect: 'sqlite',
storage: 'db.sqlite',
logging: false
});
this.db.sync();
}
public async init(userKeys: object, guildKeys: object) {
this.userData = this.db.define('user_data', {
user_id: { type: DataTypes.STRING, unique: true, primaryKey: true },
...userKeys
});
this.guildData = this.db.define('guild_data', {
guild_id: { type: DataTypes.STRING, unique: true, primaryKey: true },
...guildKeys
});
await this.db.sync({ alter: true });
}
public sync(): void {
this.db.sync();
}
public async getUser(userId: string) {
if (!this.userData) throw new Error('Database not initialized');
const [user] = await this.userData.findOrCreate({
where: { user_id: userId },
defaults: { user_id: userId }
});
return user;
}
public async getGuild(guildId: string) {
if (!this.guildData) throw new Error('Database not initialized');
const [guild] = await this.guildData.findOrCreate({
where: { guild_id: guildId },
defaults: { guild_id: guildId }
});
return guild;
}
/*
singleton logic
*/
static #instance: DatabaseManager | null = null;
public static get get(): DatabaseManager {
if (!DatabaseManager.#instance)
DatabaseManager.#instance = new DatabaseManager();
return DatabaseManager.#instance;
}
}
+262
View File
@@ -0,0 +1,262 @@
import { createHash, randomBytes } from 'crypto';
import { TTSModule, TTSResponse } from '../tts';
import * as https from 'https';
import { WebSocket } from 'ws';
import { Logger } from '../../utils/log';
const CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const AZURE_ENDPOINT = 'speech.platform.bing.com';
const READALOUD_PATH = `/consumer/speech/synthesize/readaloud`;
const WEBSOCKET_URL = `wss://${AZURE_ENDPOINT}${READALOUD_PATH}/edge/v1?TrustedClientToken=${CLIENT_TOKEN}`;
const VOICES_PATH = `${READALOUD_PATH}/voices/list?TrustedClientToken=${CLIENT_TOKEN}`;
const CHROME_VERSION = '138.0.7204.157';
const SEC_VERSION = `1-${CHROME_VERSION}`;
const USER_AGENT = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_VERSION.split('.')[0]}.0.0.0 Safari/537.36 Edg/${CHROME_VERSION.split('.')[0]}.0.0.0`;
const WIN_EPOCH = 11644473600;
const WS_RECONNECT_DELAY = 2000;
const MAX_RECONNECT_ATTEMPTS = 5;
interface PendingRequest {
resolve: (value: TTSResponse) => void;
reject: (reason: Error) => void;
audioBuff: Buffer[];
}
interface VoiceInfo {
// Name: string;
ShortName: string,
// Gender: string,
// Locale: string,
}
class AzureTTS implements TTSModule {
private voices: Array<string> | undefined = undefined;
public name: string = 'Azure';
public defaultVoice: string = 'en-US-AvaNeural';
private ready: boolean = false;
private readyPromise: Promise<void> | null = null;
private readyResolve: (() => void) | null = null;
private ws: WebSocket | undefined = undefined;
private reconnectAttempts: number = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private isReconnecting: boolean = false;
private log: Logger;
// Map keyed by X-RequestId
private pendingRequests: Map<string, PendingRequest> = new Map();
constructor() {
this.log = new Logger('Azure TTS');
this.initializeConnection();
}
async getVoices(): Promise<Array<string> | undefined> {
if (this.voices) return this.voices;
const options: https.RequestOptions = {
hostname: AZURE_ENDPOINT,
path: `${VOICES_PATH}&Sec-MS-GEC=${this.genSecToken()}&Sec-MS-GEC-Version=${SEC_VERSION}`,
method: 'GET',
headers: {
Pragma: 'no-cache',
'Cache-Control': 'no-cache',
'User-Agent': USER_AGENT,
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en-US,en;q=0.9',
Authority: 'speech.platform.bing.com',
'Sec-CH-UA': `" Not;A Brand";v="99", "Microsoft Edge";v="${CHROME_VERSION.split('.')[0]}", "Chromium";v="${CHROME_VERSION.split('.')[0]}"`,
'Sec-CH-UA-Mobile': '?0',
Accept: '*/*',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Dest': 'empty'
}
};
return new Promise((resolve) => {
const req = https.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString();
this.voices = JSON.parse(body).map((v: VoiceInfo) => v.ShortName);
resolve(this.voices);
});
req.on('error', (err) => {
throw err;
});
res.on('aborted', () => {
throw new Error('Response aborted');
});
});
req.end();
});
}
async generate(voice: string, text: string): Promise<TTSResponse> {
await this.readyPromise;
if (!this.ready || !this.ws) return { error: 'Not initialized' };
const reqId = randomBytes(16).toString('hex');
const lang = voice.split('-').slice(0, 2).join('-');
return new Promise((resolve, reject) => {
this.pendingRequests.set(reqId, { resolve, reject, audioBuff: [] });
const headers = `X-RequestId:${reqId}\r\nContent-Type:application/ssml+xml\r\nPath:ssml\r\n\r\n`;
const ssml = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${lang}"><voice name="${voice}"><prosody rate="default" pitch="default">${this.escapeXml(text)}</prosody></voice></speak>`;
this.ws?.send(headers + ssml, (err) => {
if (err) {
this.pendingRequests.delete(reqId);
reject(err);
}
});
});
}
canBeUsed(): boolean {
return true;
}
private initializeConnection(): void {
this.ready = false;
this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve;
this.connect();
});
}
private connect(): void {
const url = `${WEBSOCKET_URL}&Sec-MS-GEC=${this.genSecToken()}&Sec-MS-GEC-Version=${SEC_VERSION}`;
this.ws = new WebSocket(url, {
host: 'speech.platform.bing.com',
origin: 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold',
headers: {
Pragma: 'no-cache',
'User-Agent': USER_AGENT
}
});
this.ws.on('open', () => {
// this.log.verbose('WebSocket open');
this.reconnectAttempts = 0;
this.isReconnecting = false;
const config = `Content-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n
{
"context": {
"synthesis": {
"audio": {
"metadataoptions": { "sentenceBoundaryEnabled": "false", "wordBoundaryEnabled": "true" },
"outputFormat": "audio-24khz-48kbitrate-mono-mp3"
}
}
}
}`;
this.ws?.send(config.trim());
this.ready = true;
this.readyResolve?.();
});
this.ws.on('message', (data: Buffer, isBinary: boolean) => {
this.handleIncomingMessage(data, isBinary);
});
this.ws.on('close', (/*code, reason*/) => {
this.ready = false;
// this.log.verbose(`WS Closed: ${code}`);
this.rejectAllPending(new Error('Connection closed'));
this.scheduleReconnect();
});
this.ws.on('error', (err) => {
this.log.error('WS Error:', err);
});
}
private scheduleReconnect() {
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
const delay = WS_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts++);
setTimeout(() => this.connect(), delay);
}
private handleIncomingMessage(data: Buffer, isBinary: boolean) {
const message = data.toString();
const reqId = message.match(/X-RequestId:(.*?)\r\n/)?.[1];
if (!reqId) return;
const request = this.pendingRequests.get(reqId);
if (!request) return;
if (isBinary) {
const separator = 'Path:audio\r\n';
const index = data.indexOf(separator);
if (index !== -1) {
request.audioBuff.push(data.subarray(index + separator.length));
}
} else {
if (message.includes('Path:turn.end')) {
request.resolve({ data: Buffer.concat(request.audioBuff) });
this.pendingRequests.delete(reqId);
} else if (
message.includes('Path:turn.error') ||
message.includes('Path:error')
) {
request.reject(new Error('Azure synthesis error'));
this.pendingRequests.delete(reqId);
}
}
}
private rejectAllPending(err: Error) {
for (const [id, req] of this.pendingRequests) {
req.reject(err);
this.pendingRequests.delete(id);
}
}
private genSecToken(): string {
const ticks =
BigInt(Math.floor(Date.now() / 1000 + Number(WIN_EPOCH))) * 10000000n;
const roundedTicks = ticks - (ticks % 3000000000n);
const strToHash = `${roundedTicks}${CLIENT_TOKEN}`;
const hash = createHash('sha256');
hash.update(strToHash, 'ascii');
return hash.digest('hex').toUpperCase();
}
private escapeXml(unsafe: string): string {
return unsafe.replace(/[<>&"']/g, (c) => {
switch (c) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case '"':
return '&quot;';
case "'":
return '&apos;';
default:
return c;
}
});
}
}
export default new AzureTTS();
+276
View File
@@ -0,0 +1,276 @@
import { config } from '../../utils/config';
import { TTSModule, TTSResponse } from '../tts';
import * as https from 'https';
const ELEVENLABS_API_ENDPOINT = 'api.elevenlabs.io';
const FIREBASE_API_KEY = 'AIzaSyBSsRE_1Os04-bxpd5JTLIniy3UK4OqKys';
const FIREBASE_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
/*
TO-DO: Implement previous text
*/
interface ElevenLabsVoice {
voice_id: string;
name: string;
// ...
}
interface ElevenLabsModel {
model_id: string;
name: string;
// ...
}
interface ElevenLabsVoicesRes {
voices?: Array<ElevenLabsVoice>;
}
interface ElevenLabsVoiceSettings {
stability: number;
similarity_boost: number;
style: number;
speed: number;
user_speaker_boost: boolean;
}
interface ElevenLabsStreamRequest {
text: string;
model_id: string;
voice_settings: ElevenLabsVoiceSettings;
}
interface FirebaseSession {
idToken: string;
refreshToken: string;
expiresAt: number;
}
export class ElevenLabsTTS implements TTSModule {
private voices: Array<ElevenLabsVoice> | undefined = undefined;
private models: Array<ElevenLabsModel> | undefined = undefined;
public name: string = 'ElevenLabs';
public settings: ElevenLabsVoiceSettings;
public modelId: string;
private session: FirebaseSession | undefined = undefined;
private initializationPromise: Promise<void> | undefined = undefined;
public static readonly DEFAULT_SETTINGS: ElevenLabsVoiceSettings = {
stability: 0.0,
similarity_boost: 0.5,
style: 1.0,
speed: 1.0,
user_speaker_boost: true
};
constructor() {
this.settings = ElevenLabsTTS.DEFAULT_SETTINGS;
this.modelId = 'eleven_v3';
if (this.canBeUsed()) this.initializationPromise = this.init();
this.setSettings = this.setSettings.bind(this);
this.setModel = this.setModel.bind(this);
this.getModels = this.getModels.bind(this);
}
private async init(): Promise<void> {
await this.ensureSession();
await Promise.all([this.fetchVoices(), this.fetchModels()]);
}
/*
TTSModule methods
*/
async getVoices(): Promise<Array<string> | undefined> {
if (this.voices) return this.voices.map((voice) => voice.name);
}
async generate(voice: string, text: string): Promise<TTSResponse> {
await this.initializationPromise;
await this.ensureSession();
if (!this.voices) return { error: 'no voices' };
if (!this.session) return { error: 'no session' };
const voiceData = this.voices.find((entry) => entry.name === voice);
if (!voiceData) return { error: 'Invalid voice' };
const options: https.RequestOptions = {
hostname: ELEVENLABS_API_ENDPOINT,
path: `/v1/text-to-speech/${voiceData.voice_id}/stream`,
method: 'POST',
headers: {
accept: 'application/json',
'Content-Type': 'application/json',
origin: 'https://elevenlabs.io',
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
'Sec-Ch-Ua': '"Not)A;Brand";v="8", "Chromium";v="138"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Site': 'same-site',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Dest': 'empty',
host: 'api.elevenlabs.io',
Authorization: `Bearer ${this.session.idToken}`
}
};
const body: ElevenLabsStreamRequest = {
text: text,
model_id: this.modelId,
voice_settings: this.settings
};
return new Promise((resolve) => {
const req = https.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({
data: Buffer.concat(chunks)
});
});
});
req.on('error', (error) => resolve({ error: error.message }));
req.write(JSON.stringify(body));
req.end();
});
}
canBeUsed(): boolean {
return config.tts_elevenlabs_refreshtoken != undefined;
}
/*
ElevenLabs specific methods
*/
public setSettings(settings: Partial<ElevenLabsVoiceSettings>) {
this.settings = { ...this.settings, ...settings };
}
public setModel(name: string) {
if (!this.models) return;
const model = this.models.find((mod) => mod.name == name);
if (!model) return;
this.modelId = model.model_id;
}
public getModels(): Array<string> {
if (!this.models) return [];
return this.models.map((mod) => mod.name);
}
private async fetchVoices(): Promise<void> {
if (!this.session) return;
const opt: https.RequestOptions = {
hostname: ELEVENLABS_API_ENDPOINT,
path: '/v2/voices',
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this.session.idToken}`,
'Content-Type': 'application/json'
}
};
return new Promise((resolve) => {
const req = https.get(opt, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const voicesJSON = Buffer.concat(chunks).toString('utf-8');
const voicesParsed = JSON.parse(voicesJSON) as ElevenLabsVoicesRes;
if (!voicesParsed.voices) {
console.error('ElevenLabs voice fetch responded:', voicesJSON);
throw new Error('Failed to get ElevenLabs voices');
}
this.voices = voicesParsed.voices;
resolve();
});
});
req.on('error', (err) => {
console.error('Failed to get ElevenLabs voices:', err);
throw err;
});
});
}
private async fetchModels(): Promise<void> {
if (!this.session) return;
const opt: https.RequestOptions = {
hostname: ELEVENLABS_API_ENDPOINT,
path: '/v1/models',
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this.session.idToken}`,
'Content-Type': 'application/json'
}
};
return new Promise((resolve) => {
const req = https.get(opt, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const modelsJSON = Buffer.concat(chunks).toString('utf-8');
const modelsParsed = JSON.parse(modelsJSON) as Array<ElevenLabsModel>;
this.models = modelsParsed;
resolve();
});
});
req.on('error', (err) => {
console.error('Failed to get ElevenLabs models:', err);
throw err;
});
});
}
private async ensureSession(): Promise<void> {
if (this.session && Date.now() < this.session.expiresAt - 300000) return;
const refreshToken =
this.session?.refreshToken || config.tts_elevenlabs_refreshtoken;
if (!refreshToken) throw new Error('No refresh token available');
const response = await fetch(FIREBASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Referer: 'https://elevenlabs.io/',
Origin: 'https://elevenlabs.io'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
})
});
if (!response.ok)
throw new Error(`Auth Refresh Failed: ${await response.text()}`);
const data = await response.json();
this.session = {
idToken: data.id_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + parseInt(data.expires_in) * 1000
};
}
}
export default new ElevenLabsTTS();
+55
View File
@@ -0,0 +1,55 @@
import { TTSModule, TTSResponse } from '../tts';
import * as https from 'https';
import GOOGLE_TTS_VOICES from './google_voices.json';
const GOOGLE_TTS_ENDPOINT = 'translate.google.com';
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3';
const ttsGoogle: TTSModule = {
name: 'Google',
defaultVoice: 'en',
async getVoices(): Promise<string[]> {
return GOOGLE_TTS_VOICES.voices;
},
async generate(voice: string, text: string): Promise<TTSResponse> {
const query = new URLSearchParams({
ie: 'UTF-8',
q: text,
tl: voice,
client: 'tw-ob'
});
const options: https.RequestOptions = {
hostname: GOOGLE_TTS_ENDPOINT,
path: `/translate_tts?${query.toString()}`,
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
};
return new Promise((resolve) => {
const req = https.get(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve({ data: Buffer.concat(chunks) }));
});
req.on('error', (err) => resolve({ error: err.message }));
req.on('timeout', () => {
req.destroy();
resolve({ error: 'timed out' });
});
});
},
canBeUsed(): boolean {
return true;
}
};
export default ttsGoogle;
+79
View File
@@ -0,0 +1,79 @@
{
"voices": [
"af",
"ar",
"bn",
"bs",
"ca",
"cs",
"cy",
"da",
"de",
"el",
"en",
"en-au",
"en-ca",
"en-gb",
"en-gh",
"en-ie",
"en-in",
"en-ng",
"en-nz",
"en-ph",
"en-tz",
"en-uk",
"en-us",
"en-za",
"eo",
"es",
"es-es",
"es-us",
"et",
"fi",
"fr",
"fr-ca",
"fr-fr",
"hi",
"hr",
"hu",
"hy",
"id",
"is",
"it",
"ja",
"jw",
"km",
"ko",
"la",
"lv",
"mk",
"ml",
"mr",
"my",
"ne",
"nl",
"no",
"pl",
"pt",
"pt-br",
"pt-pt",
"ro",
"ru",
"si",
"sk",
"sq",
"sr",
"su",
"sv",
"sw",
"ta",
"te",
"th",
"tl",
"tr",
"uk",
"vi",
"zh-cn",
"zh-tw"
]
}
+14
View File
@@ -0,0 +1,14 @@
import { TTSModule, TTSResponse } from '../tts';
const ttsNone: TTSModule = {
name: 'None',
getVoices: async (): Promise<Array<string>> => [],
generate: async (): Promise<TTSResponse> => {
return { data: Buffer.from([]) };
},
canBeUsed: (): boolean => {
return true;
}
};
export default ttsNone;
+99
View File
@@ -0,0 +1,99 @@
import {
PollyClient,
DescribeVoicesCommand,
Voice,
SynthesizeSpeechCommand,
Engine
} from '@aws-sdk/client-polly';
import { TTSModule, TTSResponse } from '../tts';
import { config } from '../../utils/config';
const ENGINE_PRIORITY: Engine[] = [
'generative',
'neural',
'standard',
'long-form'
];
class PollyTTS implements TTSModule {
private client: PollyClient | undefined = undefined;
private voices: Array<Voice> | undefined = undefined;
public name: string = 'AWS Polly';
constructor() {
if (!config.aws_access_id || !config.aws_access_key) return;
this.client = new PollyClient({
credentials: {
accessKeyId: config.aws_access_id,
secretAccessKey: config.aws_access_key
}
});
}
async getVoices(): Promise<Array<string> | undefined> {
if (!this.client) return [];
if (!this.voices) {
const cmd = new DescribeVoicesCommand({});
try {
const res = await this.client.send(cmd);
if (res.Voices) this.voices = res.Voices;
} catch (err) {
console.error('AWS Polly getVoices error:', err);
}
}
if (this.voices)
return this.voices.map((voice) => `${voice.LanguageCode} ${voice.Id}`);
}
async generate(voice: string, text: string): Promise<TTSResponse> {
if (!this.client || !this.voices) return { data: Buffer.from([]) };
voice = voice.split(' ').slice(1).join(' ');
const voiceData = this.voices.find((voiceDesc) => voiceDesc.Name == voice);
if (!voiceData) return {};
const bestEngine = this.getBestEngine(voiceData);
if (!bestEngine) return {};
const cmd = new SynthesizeSpeechCommand({
Engine: bestEngine,
LanguageCode: voiceData.LanguageCode,
OutputFormat: 'mp3',
Text: text,
VoiceId: voiceData.Id
});
try {
const res = await this.client.send(cmd);
if (!res.AudioStream) return {};
const buffer = Buffer.from(await res.AudioStream.transformToByteArray());
return { data: buffer };
} catch (err) {
console.error('AWS Polly gen error:', err);
}
return {};
}
canBeUsed(): boolean {
if (!config.aws_access_id || !config.aws_access_key) return false;
return true;
}
private getBestEngine(voice: Voice): Engine | null {
if (!voice.SupportedEngines || voice.SupportedEngines.length === 0) {
return null;
}
const supportedSet = new Set(voice.SupportedEngines);
return ENGINE_PRIORITY.find((engine) => supportedSet.has(engine)) || null;
}
}
export default new PollyTTS();
+115
View File
@@ -0,0 +1,115 @@
import { config } from '../../utils/config';
import { TTSModule, TTSResponse } from '../tts';
import * as https from 'https';
import * as zlib from 'zlib';
import TIKTOK_TTS_VOICES from './tiktok_voices.json';
const TIKTOK_API_ENDPOINT = 'api16-normal-v6.tiktokv.com';
class TikTokTTS implements TTSModule {
public name: string = 'TikTok';
public defaultVoice: string = 'en_us_001';
async getVoices(): Promise<Array<string> | undefined> {
return TIKTOK_TTS_VOICES.voices;
}
async generate(voice: string, text: string): Promise<TTSResponse> {
const reqText = encodeURIComponent(text);
const path = `/media/api/text/speech/invoke/?text_speaker=${voice}&req_text=${reqText}&speaker_map_type=0&aid=1233`;
const options: https.RequestOptions = {
hostname: TIKTOK_API_ENDPOINT,
path: path,
method: 'POST',
headers: {
'User-Agent':
'com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; Build/NRD90M;tt-ok/3.12.13.1)',
Cookie: `sessionid=${config.tts_tiktok_sessionid}`,
'Accept-Encoding': 'gzip,deflate,compress',
'Content-Type': 'application/x-www-form-urlencoded'
}
};
return new Promise((resolve) => {
const req = https.request(options, (res) => {
const chunks: Buffer[] = [];
const encoding = res.headers['content-encoding'];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const decompressBuffer = (buf: Buffer): Promise<Buffer> => {
return new Promise((decompressResolve, decompressReject) => {
if (encoding === 'gzip' || encoding === 'deflate') {
zlib.unzip(buf, (err: Error | null, decompressed: Buffer) => {
if (err) decompressReject(err);
else decompressResolve(decompressed);
});
} else {
decompressResolve(buf);
}
});
};
decompressBuffer(buffer)
.then((decompressed) => {
const result = JSON.parse(decompressed.toString());
const statusCode = result?.status_code;
if (statusCode !== 0) {
const errorMsg = this.handleStatusError(statusCode);
return resolve({ error: errorMsg });
}
const voiceStr = result?.data?.v_str;
if (!voiceStr) {
return resolve({ error: 'No audio data received' });
}
resolve({ data: Buffer.from(voiceStr, 'base64') });
})
.catch((err) => {
resolve({ error: `Decompression/Parse error: ${err.message}` });
});
} catch (err) {
resolve({ error: `Parse error: ${err}` });
}
});
});
req.on('error', (err) => resolve({ error: err.message }));
req.on('timeout', () => {
req.destroy();
resolve({ error: 'timed out' });
});
req.write('');
req.end();
});
}
canBeUsed(): boolean {
return config.tts_tiktok_sessionid != undefined;
}
handleStatusError(code: number): string {
switch (code) {
case 1:
return 'Session ID may be invalid or expired';
case 2:
return 'Text is too long';
case 4:
return 'Invalid voice';
case 5:
return 'No session id.';
}
return `Unknown error code: ${code}`;
}
}
export default new TikTokTTS();
+99
View File
@@ -0,0 +1,99 @@
{
"voices": [
"en_us_ghostface",
"en_us_chewbacca",
"en_us_c3po",
"en_us_stitch",
"en_us_stormtrooper",
"en_us_rocket",
"en_female_madam_leota",
"en_male_ghosthost",
"en_male_pirate",
"en_au_001",
"en_au_002",
"en_uk_001",
"en_uk_003",
"en_us_001",
"en_us_002",
"en_us_006",
"en_us_007",
"en_us_009",
"en_us_010",
"en_male_jomboy",
"en_male_cody",
"en_female_samc",
"en_female_makeup",
"en_female_richgirl",
"en_male_grinch",
"en_male_deadpool",
"en_male_jarvis",
"en_male_ashmagic",
"en_male_olantekkers",
"en_male_ukneighbor",
"en_male_ukbutler",
"en_female_shenna",
"en_female_pansino",
"en_male_trevor",
"en_female_betty",
"en_male_cupid",
"en_female_grandma",
"en_male_m2_xhxs_m03_christmas",
"en_male_santa_narration",
"en_male_sing_deep_jingle",
"en_male_santa_effect",
"en_female_ht_f08_newyear",
"en_male_wizard",
"en_female_ht_f08_halloween",
"fr_001",
"fr_002",
"de_001",
"de_002",
"es_002",
"es_mx_002",
"br_001",
"br_003",
"br_004",
"br_005",
"bp_female_ivete",
"bp_female_ludmilla",
"pt_female_lhays",
"pt_female_laizza",
"pt_male_bueno",
"id_001",
"jp_001",
"jp_003",
"jp_005",
"jp_006",
"kr_002",
"kr_003",
"kr_004",
"jp_female_fujicochan",
"jp_female_hasegawariona",
"jp_male_keiichinakano",
"jp_female_oomaeaika",
"jp_male_yujinchigusa",
"jp_female_shirou",
"jp_male_tamawakazuki",
"jp_female_kaorishoji",
"jp_female_yagishaki",
"jp_male_hikakin",
"jp_female_rei",
"jp_male_shuichiro",
"jp_male_matsudake",
"jp_female_machikoriiita",
"jp_male_matsuo",
"jp_male_osada",
"en_female_f08_salut_damour",
"en_male_m03_lobby",
"en_female_f08_warmy_breeze",
"en_male_m03_sunshine_soon",
"en_female_ht_f08_glorious",
"en_male_sing_funny_it_goes_up",
"en_male_m2_xhxs_m03_silly",
"en_female_ht_f08_wonderful_world",
"en_male_sing_funny_thanksgiving",
"en_male_narration",
"en_male_funny",
"en_female_emotional"
]
}
+79
View File
@@ -0,0 +1,79 @@
import path from 'path';
import fs from 'fs';
import { Logger } from '../utils/log';
import { isModule } from '../utils/misc';
export interface TTSResponse {
error?: string;
data?: Buffer;
}
export interface TTSModule {
name: string;
defaultVoice?: string;
getVoices: () => Promise<Array<string> | undefined>;
generate: (voice: string, text: string) => Promise<TTSResponse>;
canBeUsed: () => boolean;
}
export class TTSManager {
private readonly modules: Array<TTSModule> = [];
private readonly log: Logger;
private constructor() {
this.log = new Logger('TTS Manager');
const modesFolder = path.resolve(__dirname, './tts-modes');
fs.readdirSync(modesFolder).map(
async (file) => await this.loadModule(path.join(modesFolder, file))
);
}
/*
public
*/
public getModule(name: string): TTSModule | undefined {
return this.modules.find((mod) => mod.name == name);
}
public getModules(): TTSModule[] {
return this.modules;
}
/*
internal
*/
private async loadModule(filePath: string): Promise<void> {
try {
if (!isModule(filePath)) return;
const modRaw = await import(`file://${filePath}`);
if (!modRaw) {
this.log.warning('Mod import failed for %s', filePath);
return;
}
const mod = modRaw.default?.default || modRaw.default || modRaw;
if (!mod.name || typeof mod.generate !== 'function') {
this.log.warning('Invalid module format in %s', filePath);
return;
}
this.log.verbose(`Loaded TTS mode: ${mod.name}`);
this.modules.push(mod);
} catch (err) {
this.log.error('Failed to load TTS Module at %s (%s)', filePath, err);
}
}
/*
singleton logic
*/
static #instance: TTSManager = new TTSManager();
public static get get(): TTSManager {
return TTSManager.#instance;
}
}
+40
View File
@@ -0,0 +1,40 @@
export interface Config {
token: string;
client_id: string;
owner_id: string | undefined;
tts_default_mode: string | undefined;
tts_default_voice: string | undefined;
tts_elevenlabs_refreshtoken: string | undefined;
tts_tiktok_sessionid: string | undefined;
steam_webapi_key: string | undefined;
aws_access_id: string | undefined;
aws_access_key: string | undefined;
}
function loadConfig(): Config {
const token = process.env.DISCORD_TOKEN;
const client_id = process.env.DISCORD_ID;
if (!token) throw new Error('DISCORD_TOKEN environment variable is not set.');
if (!client_id)
throw new Error('DISCORD_ID environment variable is not set.');
return {
token,
client_id,
owner_id: process.env.DISCORD_OWNER_ID,
tts_default_mode: process.env.DEFAULT_TTS_MODE,
tts_default_voice: process.env.DEFAULT_TTS_VOICE,
tts_elevenlabs_refreshtoken: process.env.TTS_ELEVENLABS_REFRESHTOKEN,
steam_webapi_key: process.env.STEAM_WEBAPI_KEY,
aws_access_id: process.env.AWS_ACCESS_ID,
aws_access_key: process.env.AWS_ACCESS_KEY,
tts_tiktok_sessionid: process.env.TTS_TIKTOK_SESSIONID
};
}
export const config = loadConfig();
+62 -63
View File
@@ -7,93 +7,92 @@ import 'colorts/lib/string';
helper fns
*/
function getCallerFrame(shift: number = 0): NodeJS.CallSite | undefined {
const prepareStackTrace = Error.prepareStackTrace;
let loggedStack: NodeJS.CallSite[] | undefined;
const prepareStackTrace = Error.prepareStackTrace;
let loggedStack: NodeJS.CallSite[] | undefined;
Error.prepareStackTrace = (_, stackTraces: NodeJS.CallSite[]) => {
loggedStack = stackTraces;
stackTraces.shift(); // discard curr frame
return stackTraces;
};
Error.prepareStackTrace = (_, stackTraces: NodeJS.CallSite[]) => {
loggedStack = stackTraces;
stackTraces.shift(); // discard curr frame
return stackTraces;
};
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
new Error().stack;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
new Error().stack;
Error.prepareStackTrace = prepareStackTrace;
Error.prepareStackTrace = prepareStackTrace;
if (!loggedStack) return undefined;
if (!loggedStack) return undefined;
if (shift > 0)
for (let i = 0; i < shift; i++)
loggedStack.shift();
if (shift > 0) for (let i = 0; i < shift; i++) loggedStack.shift();
return loggedStack[1];
return loggedStack[1];
}
/*
logger
*/
const LOG_TYPES = {
Info: "[Info]".green,
Verbose: "[Verbose]".blue,
Warning: "[Warning]".yellow,
Error: "[Error]".red,
Info: '[Info]'.green,
Verbose: '[Verbose]'.blue,
Warning: '[Warning]'.yellow,
Error: '[Error]'.red
} as const;
export class Logger {
private readonly name: string;
private readonly name: string;
constructor(name: string) {
this.name = name;
}
constructor(name: string) {
this.name = name;
}
public info(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Info, fmt, args);
}
public info(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Info, fmt, args);
}
public verbose(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Verbose, fmt, args);
}
public verbose(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Verbose, fmt, args);
}
public warning(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Warning, fmt, args);
}
public warning(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Warning, fmt, args);
}
public error(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Error, fmt, args);
}
public error(fmt: string, ...args: unknown[]): void {
this.log(LOG_TYPES.Error, fmt, args);
}
private log(type: string, message: string, args: unknown[]): void {
const caller = getCallerFrame(1);
if (!caller) {
console.error('Failed to determine caller information');
return;
}
private log(type: string, message: string, args: unknown[]): void {
const caller = getCallerFrame(1);
if (!caller) {
console.error('Failed to determine caller information');
return;
}
const timestamp = this.getFormattedTime();
const callerInfo = this.getCallerInfo(caller);
const formattedMessage = util.format(message, ...args);
const timestamp = this.getFormattedTime();
const callerInfo = this.getCallerInfo(caller);
const formattedMessage = util.format(message, ...args);
console.log(
`${timestamp} - ${callerInfo} [${this.name.magenta}] ${type} ${formattedMessage}`
);
}
console.log(
`${timestamp} - ${callerInfo} [${this.name.magenta}] ${type} ${formattedMessage}`
);
}
private getFormattedTime(): string {
const now = new Date();
return now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
private getFormattedTime(): string {
const now = new Date();
return now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
private getCallerInfo(caller: NodeJS.CallSite): string {
const functionName = caller.getFunctionName()?.replace(/\[.*\]/, '') || '<anonymous>';
const fileName = caller.getFileName() || 'unknown';
const relativePath = path.relative(process.cwd(), fileName);
private getCallerInfo(caller: NodeJS.CallSite): string {
const functionName =
caller.getFunctionName()?.replace(/\[.*\]/, '') || '<anonymous>';
const fileName = caller.getFileName() || 'unknown';
const relativePath = path.relative(process.cwd(), fileName);
return `${functionName.cyan} @ ${relativePath.dim}`;
}
return `${functionName.cyan} @ ${relativePath.dim}`;
}
}
+9
View File
@@ -0,0 +1,9 @@
const MOD_EXCLUDE: Array<string> = ['.d.ts'];
const MOD_INCLUDE: Array<string> = ['.ts', '.js', '.mjs', '.cjs'];
export function isModule(path: string): boolean {
for (const ext of MOD_EXCLUDE) if (path.endsWith(ext)) return false;
for (const ext of MOD_INCLUDE) if (path.endsWith(ext)) return true;
return false;
}
+10 -21
View File
@@ -1,39 +1,28 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "./src",
"outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "es2024",
"types": ["node"],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"types": [
"node"
],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
// "verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
},
"include": [
"src/**/*"
],
"exclude": [
"eslint.config.mts"
]
}