Compare commits
122 Commits
710de16af1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20e162dc32 | |||
| 09e10e4113 | |||
| cbb5a9a76a | |||
| 9025831f3d | |||
| 06926e5601 | |||
| 85c35021b5 | |||
| c44f92f777 | |||
| 1927728b60 | |||
| 99b06b574b | |||
| 7c3a5f6b56 | |||
| 27a6807340 | |||
| 2fe0551dee | |||
| 91a4c6e40d | |||
| 69ee765889 | |||
| d972e6598e | |||
| 2f4e944df4 | |||
| 9571e32e61 | |||
| b246afdc7f | |||
| 123ed75b60 | |||
| 51ebb6c92d | |||
| 8e7a71164d | |||
| 224d1339e9 | |||
| d9c623ac5c | |||
| 68e622d318 | |||
| fea589dc2c | |||
| feabc732cf | |||
| bfc749a034 | |||
| 11539d149b | |||
| 7cbb5f3a9f | |||
| f218a2cef9 | |||
| 0cda7dd110 | |||
| ce98f13efd | |||
| 697cfd1de1 | |||
| f282a77411 | |||
| 049897fb07 | |||
| 17df430122 | |||
| 042fde30c4 | |||
| c7ff5d3659 | |||
| 893511ee11 | |||
| 5bc4cd02ec | |||
| 30966ec81a | |||
| f7558913ee | |||
| 6d21c3deca | |||
| 14194d07ff | |||
| b3109d643d | |||
| 00e02b9f97 | |||
| fd75f692d5 | |||
| e1363de9df | |||
| 0fc38828be | |||
| c849c8ee11 | |||
| 426c97e654 | |||
| 60b66027a3 | |||
| fda4bd91aa | |||
| 7b4dfb0dce | |||
| 8efdf0bc5b | |||
| 449c4efbb7 | |||
| 4932bd18d3 | |||
| 726fd914e4 | |||
| 753405c504 | |||
| 294e256feb | |||
| c005bc0e54 | |||
| 8e8d5dc479 | |||
| dfb58318af | |||
| 4abc2ff594 | |||
| 5877644ed9 | |||
| 71da6e841d | |||
| c1d355993e | |||
| a401fdab15 | |||
| c5e6395b89 | |||
| 0c394bdcbe | |||
| efa52dffbc | |||
| 3354289207 | |||
| 4fbf308650 | |||
| 4033b6f6b5 | |||
| d3a6decef6 | |||
| 4718e68c78 | |||
| 7c282105d3 | |||
| bff1bf3856 | |||
| f87171590e | |||
| c96ba7e63d | |||
| 21c69329ee | |||
| 8e98b38fa8 | |||
| 07da7538ba | |||
| 889b86b019 | |||
| cfae674cb4 | |||
| 7d0b5dc459 | |||
| af7c25e6ec | |||
| 9b4d1cbe24 | |||
| aba00e3599 | |||
| 5f1a32dcb3 | |||
| 2ec7212cd3 | |||
| 83569a27e7 | |||
| 02949e8b16 | |||
| 645d58ca21 | |||
| 905713a08d | |||
| b4cb95793b | |||
| 0e171894d5 | |||
| 40487d9634 | |||
| 827af77895 | |||
| e3ad17d25e | |||
| ace276b2a8 | |||
| c9c88baa11 | |||
| 3e0654047d | |||
| ae6296e0ae | |||
| 20540f7b53 | |||
| 207b16e1cd | |||
| f69a62a314 | |||
| 6ad0ba5340 | |||
| 9e2e35f595 | |||
| 4729977f93 | |||
| 1213f4fadf | |||
| 4a2524aedc | |||
| f424336eba | |||
| efe2cb7458 | |||
| eb40c4d736 | |||
| 5d29e531e5 | |||
| 8e3f2c3904 | |||
| fd16528c07 | |||
| 7bb7d6abdb | |||
| edaf360e2e | |||
| d975547efe | |||
| f0e8f5e939 |
@@ -1,4 +1,7 @@
|
|||||||
node_modules/*
|
node_modules/*
|
||||||
dist/*
|
dist/*
|
||||||
|
|
||||||
|
db.sqlite
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
@@ -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"/>
|
||||||
@@ -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
@@ -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"]
|
||||||
+1
-1
@@ -5,5 +5,5 @@ import { defineConfig } from "eslint/config";
|
|||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{ files: ["src/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
|
{ files: ["src/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended, { ignores: ["dist/**", "**/dist/**"] }
|
||||||
]);
|
]);
|
||||||
|
|||||||
Generated
+3659
-11
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -8,7 +8,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rimraf dist",
|
||||||
"build:clean": "npm run clean && npm run build",
|
"build:clean": "npm run clean && npm run build",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier --check './src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc",
|
"format": "prettier --check './src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc",
|
||||||
@@ -28,14 +28,23 @@
|
|||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
|
"rimraf": "^6.1.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.52.0"
|
"typescript-eslint": "^8.52.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@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",
|
"colorts": "^0.1.63",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"prettier": "^3.7.4"
|
"node-audio-mixer": "^2.1.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prism-media": "^1.3.5",
|
||||||
|
"sequelize": "^6.37.7",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
+99
-70
@@ -10,19 +10,19 @@ import {
|
|||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { Logger } from './utils/log';
|
import { Logger } from './utils/log';
|
||||||
import { config } from './utils/config';
|
import { config } from './utils/config';
|
||||||
|
import { Command, CommandCategory, CommandManager } from './commands';
|
||||||
|
import { DatabaseManager } from './modules/db';
|
||||||
|
|
||||||
type BotEventListeners = {
|
type BotEventListeners = {
|
||||||
'messageCreate': (message: Message) => void;
|
messageCreate: (message: Message) => void;
|
||||||
'interactionCreate': (interaction: Interaction) => void;
|
interactionCreate: (interaction: Interaction) => void;
|
||||||
'voiceStateUpdate': (oldState: VoiceState, newState: VoiceState) => void;
|
voiceStateUpdate: (oldState: VoiceState, newState: VoiceState) => void;
|
||||||
};
|
};
|
||||||
type BotEventListener<K extends keyof BotEventListeners> = BotEventListeners[K];
|
type BotEventListener<K extends keyof BotEventListeners> = BotEventListeners[K];
|
||||||
|
|
||||||
export class Bot {
|
export class Bot {
|
||||||
private client: Client | undefined;
|
private client: Client | undefined;
|
||||||
|
private cmdMgr: CommandManager;
|
||||||
private readonly token: string;
|
|
||||||
private readonly clientId: string;
|
|
||||||
|
|
||||||
private readonly log: Logger;
|
private readonly log: Logger;
|
||||||
|
|
||||||
@@ -30,70 +30,12 @@ export class Bot {
|
|||||||
[K in keyof BotEventListeners]?: BotEventListener<K>[];
|
[K in keyof BotEventListeners]?: BotEventListener<K>[];
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
/*
|
|
||||||
event methods
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
class methods
|
class methods
|
||||||
*/
|
*/
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.log = new Logger('Bot');
|
this.log = new Logger('Bot');
|
||||||
|
this.cmdMgr = new CommandManager('./commands');
|
||||||
this.token = config.token;
|
|
||||||
this.clientId = config.client_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(): Promise<void> {
|
public async init(): Promise<void> {
|
||||||
@@ -102,6 +44,15 @@ export class Bot {
|
|||||||
if (this.client)
|
if (this.client)
|
||||||
throw new Error('Client already exists, was init called twice?');
|
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.log.info('Instantiating client');
|
||||||
this.client = new Client({
|
this.client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -116,12 +67,28 @@ export class Bot {
|
|||||||
this.client.on('clientReady', () => this.onReady());
|
this.client.on('clientReady', () => this.onReady());
|
||||||
this.client.on('error', (err) => this.onError(err, false));
|
this.client.on('error', (err) => this.onError(err, false));
|
||||||
this.client.on('shardError', (err) => this.onError(err, true));
|
this.client.on('shardError', (err) => this.onError(err, true));
|
||||||
this.client.on('messageCreate', (message: Message<boolean>) => this.onMessage(message));
|
this.client.on('messageCreate', (message: Message<boolean>) =>
|
||||||
this.client.on('interactionCreate', (interaction: Interaction<CacheType>) => this.onInteraction(interaction));
|
this.onMessage(message)
|
||||||
this.client.on('voiceStateUpdate', (oldState: VoiceState, newState: VoiceState) => this.onVoiceStateUpdate(oldState, newState));
|
);
|
||||||
|
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...');
|
this.log.info('Logging in...');
|
||||||
await this.client.login(this.token);
|
await this.client.login(config.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCommands(): Array<Command> {
|
||||||
|
return this.cmdMgr.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCategories(): Array<CommandCategory> {
|
||||||
|
return this.cmdMgr.getCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -178,10 +145,72 @@ export class Bot {
|
|||||||
this.emit('messageCreate', message);
|
this.emit('messageCreate', message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onVoiceStateUpdate(oldState: VoiceState, newState: VoiceState): Promise<void> {
|
private async onVoiceStateUpdate(
|
||||||
|
oldState: VoiceState,
|
||||||
|
newState: VoiceState
|
||||||
|
): Promise<void> {
|
||||||
this.emit('voiceStateUpdate', oldState, newState);
|
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
|
singleton logic
|
||||||
*/
|
*/
|
||||||
|
|||||||
+352
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { CommandCategoryInfo } from '../../commands';
|
||||||
|
|
||||||
|
const info: CommandCategoryInfo = {
|
||||||
|
name: 'Bot',
|
||||||
|
description: 'Bot management commands'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default info;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { CommandCategoryInfo } from '../../commands';
|
||||||
|
|
||||||
|
const info: CommandCategoryInfo = {
|
||||||
|
name: 'General',
|
||||||
|
description: 'General / uncategorized commands'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default info;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { CommandCategoryInfo } from '../../commands';
|
||||||
|
|
||||||
|
const info: CommandCategoryInfo = {
|
||||||
|
name: 'TTS',
|
||||||
|
description: 'Text to Speech related commands'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default info;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { CommandCategoryInfo } from '../../commands';
|
||||||
|
|
||||||
|
const info: CommandCategoryInfo = {
|
||||||
|
name: 'Voice',
|
||||||
|
description: 'Voice chat related commands'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default info;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '<';
|
||||||
|
case '>':
|
||||||
|
return '>';
|
||||||
|
case '&':
|
||||||
|
return '&';
|
||||||
|
case '"':
|
||||||
|
return '"';
|
||||||
|
case "'":
|
||||||
|
return ''';
|
||||||
|
default:
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AzureTTS();
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-5
@@ -6,10 +6,13 @@ export interface Config {
|
|||||||
tts_default_mode: string | undefined;
|
tts_default_mode: string | undefined;
|
||||||
tts_default_voice: string | undefined;
|
tts_default_voice: string | undefined;
|
||||||
|
|
||||||
tts_azure_key: string | undefined;
|
tts_elevenlabs_refreshtoken: string | undefined;
|
||||||
tts_elevenlabs_key: string | undefined;
|
tts_tiktok_sessionid: string | undefined;
|
||||||
|
|
||||||
steam_webapi_key: string | undefined;
|
steam_webapi_key: string | undefined;
|
||||||
|
|
||||||
|
aws_access_id: string | undefined;
|
||||||
|
aws_access_key: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadConfig(): Config {
|
function loadConfig(): Config {
|
||||||
@@ -26,9 +29,11 @@ function loadConfig(): Config {
|
|||||||
owner_id: process.env.DISCORD_OWNER_ID,
|
owner_id: process.env.DISCORD_OWNER_ID,
|
||||||
tts_default_mode: process.env.DEFAULT_TTS_MODE,
|
tts_default_mode: process.env.DEFAULT_TTS_MODE,
|
||||||
tts_default_voice: process.env.DEFAULT_TTS_VOICE,
|
tts_default_voice: process.env.DEFAULT_TTS_VOICE,
|
||||||
tts_azure_key: process.env.TTS_AZURE_KEY,
|
tts_elevenlabs_refreshtoken: process.env.TTS_ELEVENLABS_REFRESHTOKEN,
|
||||||
tts_elevenlabs_key: process.env.TTS_ELEVENLABS_KEY,
|
steam_webapi_key: process.env.STEAM_WEBAPI_KEY,
|
||||||
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,39 +1,28 @@
|
|||||||
{
|
{
|
||||||
// Visit https://aka.ms/tsconfig to read more about this file
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// File Layout
|
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
// Environment Settings
|
|
||||||
// See also https://aka.ms/tsconfig/module
|
|
||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"target": "es2024",
|
"target": "es2024",
|
||||||
"types": ["node"],
|
"types": [
|
||||||
// For nodejs:
|
"node"
|
||||||
// "lib": ["esnext"],
|
],
|
||||||
// "types": ["node"],
|
|
||||||
// and npm install -D @types/node
|
|
||||||
// Other Outputs
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
// Stricter Typechecking Options
|
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
// Style Options
|
|
||||||
// "noImplicitReturns": true,
|
|
||||||
// "noImplicitOverride": true,
|
|
||||||
// "noUnusedLocals": true,
|
|
||||||
// "noUnusedParameters": true,
|
|
||||||
// "noFallthroughCasesInSwitch": true,
|
|
||||||
// "noPropertyAccessFromIndexSignature": true,
|
|
||||||
// Recommended Options
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
// "verbatimModuleSyntax": true,
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"eslint.config.mts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user