diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..175a02a --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +DATABASE_URL="postgresql://user:password@localhost/cosmic" +MPPNET_TOKEN="" +CHATBOX_LICENSE_KEY="" diff --git a/bun.lockb b/bun.lockb index e0f28ce..3101961 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config/services.yml b/config/services.yml new file mode 100644 index 0000000..42ce890 --- /dev/null +++ b/config/services.yml @@ -0,0 +1,4 @@ +enableMPP: false +enableDiscord: false +enableConsole: true +enableSwitchChat: true diff --git a/config/switchchat.yml b/config/switchchat.yml new file mode 100644 index 0000000..914d4a9 --- /dev/null +++ b/config/switchchat.yml @@ -0,0 +1 @@ +ownerOnly: false diff --git a/package.json b/package.json index 090c180..8c76226 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ "@t3-oss/env-core": "^0.7.1", "dotenv": "^16.3.1", "hyperimport": "^0.1.0", + "mathjs": "^11.11.2", "mpp-client-net": "^1.1.3", "mpp-client-xt": "^1.3.1", "prisma": "^5.4.2", + "switchchat": "^3.2.1", + "yaml": "^2.3.3", "zod": "^3.22.4" } } \ No newline at end of file diff --git a/src/commands/Command.ts b/src/commands/Command.ts new file mode 100644 index 0000000..251a4dd --- /dev/null +++ b/src/commands/Command.ts @@ -0,0 +1,20 @@ +import { ServiceAgent } from "../services/ServiceAgent"; +import { CommandMessage } from "./CommandHandler"; + +export class Command { + public static getUsage(usage: string, prefix: string) { + return usage.split("{prefix}").join(prefix); + } + + constructor( + public id: string, + public aliases: string[], + public description: string, + public usage: string, + public callback: ( + msg: CommandMessage, + agent: ServiceAgent + ) => Promise | string | void, + public visible: boolean = true + ) {} +} diff --git a/src/commands/CommandGroup.ts b/src/commands/CommandGroup.ts new file mode 100644 index 0000000..0464712 --- /dev/null +++ b/src/commands/CommandGroup.ts @@ -0,0 +1,17 @@ +import { Command } from "./Command"; + +export class CommandGroup { + public commands = new Array(); + + constructor(public id: string, public displayName: string) {} + + public addCommand(command: Command) { + this.commands.push(command); + } + + public addCommands(commands: Command[]) { + for (const command of commands) { + this.addCommand(command); + } + } +} diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts new file mode 100644 index 0000000..5d05ced --- /dev/null +++ b/src/commands/CommandHandler.ts @@ -0,0 +1,78 @@ +import { ServiceAgent } from "../services/ServiceAgent"; +import { Command } from "./Command"; +import { CommandGroup } from "./CommandGroup"; +import { Prefix } from "./Prefix"; + +export interface CommandMessage { + m: "command"; + a: string; + argv: string[]; + argc: number; + originalMessage: T; +} + +export class CommandHandler { + public static commandGroups = new Array(); + public static prefixes = new Array( + { + id: "cosmic", + spaced: true + }, + { + id: "*", + spaced: false + } + ); + + public static addCommandGroup(group: CommandGroup) { + this.commandGroups.push(group); + } + + public static async handleCommand( + msg: CommandMessage, + agent: ServiceAgent + ) { + let usedPrefix: Prefix | undefined; + + for (const prefix of this.prefixes) { + if (msg.argv[0].startsWith(prefix.id)) { + usedPrefix = prefix; + + if (prefix.spaced) { + msg.argv.splice(0, 1); + } + + break; + } + } + + if (!usedPrefix) return; + + let usedAlias = msg.argv[0]; + if (!usedPrefix.spaced) + usedAlias = msg.argv[0].substring(usedPrefix.id.length); + + if (!usedAlias) return; + + let usedCommand: Command | undefined; + + for (const group of this.commandGroups) { + for (const command of group.commands) { + if (command.aliases.includes(usedAlias)) { + usedCommand = command; + break; + } + } + } + + if (!usedCommand) return; + + try { + const out = usedCommand.callback(msg, agent); + if (out) return out; + } catch (err) { + console.error(err); + return "An error has occurred."; + } + } +} diff --git a/src/commands/Prefix.ts b/src/commands/Prefix.ts new file mode 100644 index 0000000..3737d3c --- /dev/null +++ b/src/commands/Prefix.ts @@ -0,0 +1,3 @@ +export class Prefix { + constructor(public id: string, public spaced: boolean) {} +} diff --git a/src/commands/commands/general/about.ts b/src/commands/commands/general/about.ts new file mode 100644 index 0000000..6e9a93f --- /dev/null +++ b/src/commands/commands/general/about.ts @@ -0,0 +1,11 @@ +import { Command } from "../../Command"; + +export const help = new Command( + "about", + ["about", "info"], + "get about bozo", + "{prefix}about", + (msg, agent) => { + return `This is a dumb chat bot`; + } +); diff --git a/src/commands/commands/general/help.ts b/src/commands/commands/general/help.ts new file mode 100644 index 0000000..0202d82 --- /dev/null +++ b/src/commands/commands/general/help.ts @@ -0,0 +1,11 @@ +import { Command } from "../../Command"; + +export const help = new Command( + "help", + ["help", "h", "commands", "cmds"], + "get help bozo", + "{prefix}help", + (msg, agent) => { + return "test"; + } +); diff --git a/src/commands/commands/utility/math.ts b/src/commands/commands/utility/math.ts new file mode 100644 index 0000000..4f44ab8 --- /dev/null +++ b/src/commands/commands/utility/math.ts @@ -0,0 +1,21 @@ +import { Command } from "../../Command"; + +import { evaluate } from "mathjs"; + +export const math = new Command( + "math", + ["math"], + "math bozo", + "{prefix}math", + (msg, agent) => { + try { + const argcat = msg.argv.slice(1, msg.argv.length).join(" "); + console.log(argcat); + const answer = evaluate(argcat); + return `Answer: ${answer}`; + } catch (err) { + console.error(err); + return `Invalid expression: ${err}`; + } + } +); diff --git a/src/commands/commands/utility/memory.ts b/src/commands/commands/utility/memory.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..902d0fc --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,14 @@ +import { CommandGroup } from "./CommandGroup"; +import { CommandHandler } from "./CommandHandler"; +import { help } from "./commands/general/help"; +import { math } from "./commands/utility/math"; + +export function loadCommands() { + const general = new CommandGroup("general", "General"); + general.addCommands([help]); + CommandHandler.addCommandGroup(general); + + const utility = new CommandGroup("utility", "Utility"); + utility.addCommands([math]); + CommandHandler.addCommandGroup(utility); +} diff --git a/src/index.ts b/src/index.ts index d2cc960..0a8dcc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { loadCommands } from "./commands"; import { ServiceLoader } from "./services"; +loadCommands(); ServiceLoader.loadServices(); diff --git a/src/services/console/index.ts b/src/services/console/index.ts new file mode 100644 index 0000000..16b405c --- /dev/null +++ b/src/services/console/index.ts @@ -0,0 +1,65 @@ +import { CommandHandler, CommandMessage } from "../../commands/CommandHandler"; +import { loadConfig } from "../../util/config"; +import { ServiceAgent } from "../ServiceAgent"; +import readline from "readline"; + +const config = loadConfig("config/switchchat.yml", { + ownerOnly: false +}); + +export class SwitchChatAgent extends ServiceAgent { + public desiredUser = { + name: "🟇 𝙎𝙪𝙥𝙚𝙧 Cosmic", + color: "#1d0054" + }; + + constructor(token: string) { + const cl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + super(cl); + } + + public started = false; + + public start() { + if (this.started) return; + this.started = true; + this.client.setPrompt(">"); + this.client.prompt(true); + } + + public stop() { + if (!this.started) return; + this.started = false; + this.client.setPrompt(""); + } + + protected bindEventListeners(): void { + super.bindEventListeners(); + + this.client.on("command", async cmd => { + if (config.ownerOnly && !cmd.ownerOnly) return; + + console.log( + `${cmd.user.displayName}: ${cmd.ownerOnly ? "^" : "\\"}${ + cmd.command + } ${cmd.args.join(" ")}` + ); + + const message: CommandMessage = { + m: "command", + a: `${cmd.command} ${cmd.args.join(" ")}`, + argc: cmd.args.length + 1, + argv: [cmd.command, ...cmd.args], + originalMessage: cmd + }; + + const out = await CommandHandler.handleCommand(message, this); + console.log(out); + if (out) this.client.tell(cmd.user.name, out); + }); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 55fd069..04a5430 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -2,16 +2,47 @@ import EventEmitter from "events"; import { MPPAgent } from "./mpp"; import env from "../util/env"; import { ServiceAgent } from "./ServiceAgent"; +import { loadConfig } from "../util/config"; +import { z } from "zod"; +import { SwitchChatAgent } from "./switchchat"; + +/** + * Services are anything (any platforms or environments) that the bot will directly communicate to users with + */ + +const config = loadConfig("config/services.yml", { + enableConsole: true, + enableMPP: false, + enableDiscord: false, + enableSwitchChat: false +}); export class ServiceLoader { public static agents = new Array>(); - public static loadServices() { - const testAgent = new MPPAgent( - "wss://mppclone.com:8443", - env.MPPNET_TOKEN - ); + public static addAgent(agent: ServiceAgent) { + this.agents.push(agent); + } - testAgent.start(); + public static loadServices() { + if (config.enableMPP) { + // TODO Implement URI and channel configuration + const testAgent = new MPPAgent( + "wss://mppclone.com:8443", + env.MPPNET_TOKEN + ); + + testAgent.start(); + this.addAgent(testAgent); + } + + if (config.enableSwitchChat) { + const switchChatAgent = new SwitchChatAgent( + env.CHATBOX_LICENSE_KEY + ); + + switchChatAgent.start(); + this.addAgent(switchChatAgent); + } } } diff --git a/src/services/switchchat/index.ts b/src/services/switchchat/index.ts new file mode 100644 index 0000000..4340625 --- /dev/null +++ b/src/services/switchchat/index.ts @@ -0,0 +1,61 @@ +import { CommandHandler, CommandMessage } from "../../commands/CommandHandler"; +import { loadConfig } from "../../util/config"; +import { ServiceAgent } from "../ServiceAgent"; +import { Client } from "switchchat"; + +const config = loadConfig("config/switchchat.yml", { + ownerOnly: false +}); + +export class SwitchChatAgent extends ServiceAgent { + public desiredUser = { + name: "🟇 𝙎𝙪𝙥𝙚𝙧 Cosmic", + color: "#1d0054" + }; + + constructor(token: string) { + const cl = new Client(token); + super(cl); + + this.client.defaultName = this.desiredUser.name; + this.client.defaultFormattingMode = "markdown"; + } + + public start() { + this.client.connect(); + } + + public stop() { + this.client.close(); + } + + protected bindEventListeners(): void { + super.bindEventListeners(); + + this.client.on("command", async cmd => { + if (config.ownerOnly && !cmd.ownerOnly) return; + + console.log( + `${cmd.user.displayName}: ${cmd.ownerOnly ? "^" : "\\"}${ + cmd.command + } ${cmd.args.join(" ")}` + ); + + const message: CommandMessage = { + m: "command", + a: `${cmd.command} ${cmd.args.join(" ")}`, + argc: cmd.args.length + 1, + argv: [cmd.command, ...cmd.args], + originalMessage: cmd + }; + + const out = await CommandHandler.handleCommand(message, this); + console.log(out); + if (out) await this.client.tell(cmd.user.name, out); + }); + + this.client.on("rawraw", data => { + console.log(data); + }); + } +} diff --git a/src/util/config.ts b/src/util/config.ts new file mode 100644 index 0000000..f42a0bf --- /dev/null +++ b/src/util/config.ts @@ -0,0 +1,73 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { parse, stringify } from "yaml"; +import { z } from "zod"; + +/** + * Load a YAML config file and set default values if config path is nonexistent + * + * Usage: + * ```ts + * const config = loadConfig("config/services.yml", { + * enableMPP: false + * }); + * ``` + * @param configPath Path to load config from + * @param defaultConfig Config to use if none is present (will save to path if used) + * @returns Parsed YAML config + */ +export function loadConfig(configPath: string, defaultConfig: T): T { + // Config exists? + if (existsSync(configPath)) { + // Load config + const data = readFileSync(configPath); + const config = parse(data.toString()); + + const defRecord = defaultConfig as Record; + let changed = false; + + function mix( + obj: Record, + obj2: Record + ) { + for (const key of Object.keys(obj2)) { + if (typeof obj[key] == "undefined") { + obj[key] = obj2[key]; + changed = true; + } + + if (typeof obj[key] == "object" && !Array.isArray(obj[key])) { + mix( + obj[key] as Record, + obj2[key] as Record + ); + } + } + } + + // Apply any missing default values + mix(config, defRecord); + + // Save config if modified + if (changed) writeConfig(configPath, config); + + return config as T; + } else { + // Write default config to disk and use that + writeConfig(configPath, defaultConfig); + return defaultConfig as T; + } +} + +/** + * Write a YAML config to disk + * @param configPath + * @param config + */ +export function writeConfig(configPath: string, config: T) { + writeFileSync( + configPath, + stringify(config, { + indent: 4 + }) + ); +} diff --git a/src/util/env.ts b/src/util/env.ts index c6799e7..f4be196 100644 --- a/src/util/env.ts +++ b/src/util/env.ts @@ -7,7 +7,8 @@ configDotenv(); export const env = createEnv({ isServer: true, server: { - MPPNET_TOKEN: z.string() + MPPNET_TOKEN: z.string(), + CHATBOX_LICENSE_KEY: z.string() }, runtimeEnv: process.env });