diff --git a/bun.lockb b/bun.lockb index b79560f..d76ce6a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c005eeb..d0b6738 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,25 @@ { - "name": "fishing-api", - "module": "src/api/index.ts", - "type": "module", - "devDependencies": { - "@types/bun": "latest" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "@prisma/client": "^5.9.1", - "@trpc/client": "next", - "@trpc/server": "next", - "prisma": "^5.9.1", - "trpc-bun-adapter": "^1.1.0", - "zod": "^3.22.4" - } + "name": "fishing-api", + "module": "src/api/index.ts", + "type": "module", + "scripts": { + "start": "bun .", + "start-bot": "bun src/mpp/index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@prisma/client": "^5.9.1", + "@trpc/client": "next", + "@trpc/server": "next", + "mpp-client-net": "^1.1.3", + "prisma": "^5.9.1", + "trpc-bun-adapter": "^1.1.0", + "yaml": "^2.3.4", + "zod": "^3.22.4" + } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db3cb68..dcaa515 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,21 @@ datasource db { } model User { - id String @id - name String - color String + id String @id + name String + color String + inventory Inventory @relation(fields: [inventoryId], references: [id]) + inventoryId Int @unique +} + +model Inventory { + id Int @id @default(autoincrement()) + balance Int + items Json @default("[]") + User User? +} + +model AuthToken { + id Int @id @default(autoincrement()) + token String @unique } diff --git a/src/api/util/config.ts b/src/api/api/auth.ts similarity index 100% rename from src/api/util/config.ts rename to src/api/api/auth.ts diff --git a/src/api/api/server.ts b/src/api/api/server.ts index eca625a..d62617c 100644 --- a/src/api/api/server.ts +++ b/src/api/api/server.ts @@ -1,5 +1,8 @@ import { createBunServeHandler } from "trpc-bun-adapter"; import { appRouter } from "./trpc"; +import { Logger } from "@util/Logger"; + +const logger = new Logger("Server"); export const server = Bun.serve( createBunServeHandler({ @@ -7,4 +10,6 @@ export const server = Bun.serve( }) ); +logger.info("Started on port", (process.env.PORT as string) || 3000); + export default server; diff --git a/src/api/api/trpc.ts b/src/api/api/trpc.ts index 9e95876..a30004a 100644 --- a/src/api/api/trpc.ts +++ b/src/api/api/trpc.ts @@ -1,16 +1,58 @@ -import { initTRPC } from "@trpc/server"; +import { handleCommand } from "@server/commands/handler"; +import { prefixes } from "@server/commands/prefixes"; +import { TRPCError, initTRPC } from "@trpc/server"; +import { Logger } from "@util/Logger"; import { z } from "zod"; -const t = initTRPC.create(); +export interface Context { + isAuthed: boolean; +} + +const t = initTRPC.context().create(); export const router = t.router; export const publicProcedure = t.procedure; +export const privateProcedure = publicProcedure.use(async opts => { + const { ctx } = opts; + + if (!ctx.isAuthed) throw new TRPCError({ code: "UNAUTHORIZED" }); + + return opts.next({ + ctx: { + isAuthed: true + } + }); +}); + +const logger = new Logger("tRPC"); export const appRouter = router({ - cast: publicProcedure.input(z.string()).query(async opts => { - const { input } = opts; - const response = `${input} cast their rod`; - return { response }; + prefixes: publicProcedure.query(async opts => { + return prefixes; + }), + + command: publicProcedure + .input( + z.object({ + command: z.string(), + prefix: z.string(), + args: z.array(z.string()), + user: z.object({ + id: z.string(), + name: z.string(), + color: z.string() + }) + }) + ) + .query(async opts => { + const { command, args, prefix, user } = opts.input; + const out = await handleCommand(command, args, prefix, user); + + return out; + }), + + auth: publicProcedure.input(z.string()).query(async opts => { + const token = opts.input; }) }); diff --git a/src/api/commands/Command.ts b/src/api/commands/Command.ts new file mode 100644 index 0000000..c551c85 --- /dev/null +++ b/src/api/commands/Command.ts @@ -0,0 +1,12 @@ +export class Command { + constructor( + public id: string, + public aliases: string[], + public description: string, + public usage: string, + public permissionNode: string, + public callback: TCommandCallback + ) {} +} + +export default Command; diff --git a/src/api/commands/groups/fishing/fish.ts b/src/api/commands/groups/fishing/fish.ts new file mode 100644 index 0000000..2120d7e --- /dev/null +++ b/src/api/commands/groups/fishing/fish.ts @@ -0,0 +1,12 @@ +import Command from "@server/commands/Command"; + +export const fish = new Command( + "fish", + ["fish", "fosh", "cast"], + "Send your LURE into a water for catching fish", + "fish", + "command.fishing.fish", + async () => { + return "There is no fishing yet, please come back later when I write the code for it"; + } +); diff --git a/src/api/commands/groups/general/help.ts b/src/api/commands/groups/general/help.ts new file mode 100644 index 0000000..7e83e7a --- /dev/null +++ b/src/api/commands/groups/general/help.ts @@ -0,0 +1,33 @@ +import Command from "@server/commands/Command"; +import { commandGroups } from ".."; +import { logger } from "@server/commands/handler"; + +export const help = new Command( + "help", + [ + "help", + "h", + "holp", + "halp", + "hilp", + "hulp", + "commands", + "commonds", + "cmds", + "cimminds", + "cammands", + "cummunds" + ], + "Help command", + "help [command]", + "command.general.help", + async (command, args, prefix, user) => { + return `${commandGroups + .map(group => { + return `${group.displayName}: ${group.commands + .map(cmd => cmd.aliases[0]) + .join(", ")}`; + }) + .join("\n")}`; + } +); diff --git a/src/api/commands/groups/index.ts b/src/api/commands/groups/index.ts new file mode 100644 index 0000000..bfde77f --- /dev/null +++ b/src/api/commands/groups/index.ts @@ -0,0 +1,36 @@ +import type { Command } from "../Command"; +import { fish } from "./fishing/fish"; +import { help } from "./general/help"; +import { data } from "./util/data"; + +interface ICommandGroup { + id: string; + displayName: string; + commands: Command[]; +} + +export const commandGroups: ICommandGroup[] = []; + +const general: ICommandGroup = { + id: "general", + displayName: "General", + commands: [help] +}; + +commandGroups.push(general); + +const fishing: ICommandGroup = { + id: "fishing", + displayName: "Fishing", + commands: [fish] +}; + +commandGroups.push(fishing); + +const util: ICommandGroup = { + id: "util", + displayName: "Utility", + commands: [data] +}; + +commandGroups.push(util); diff --git a/src/api/commands/groups/util/data.ts b/src/api/commands/groups/util/data.ts new file mode 100644 index 0000000..bda72af --- /dev/null +++ b/src/api/commands/groups/util/data.ts @@ -0,0 +1,10 @@ +import Command from "@server/commands/Command"; + +export const data = new Command( + "data", + ["data"], + "Data command", + "data", + "command.util.data", + async () => {} +); diff --git a/src/api/commands/handler.ts b/src/api/commands/handler.ts new file mode 100644 index 0000000..56568b4 --- /dev/null +++ b/src/api/commands/handler.ts @@ -0,0 +1,42 @@ +import { Logger } from "@util/Logger"; +import type Command from "./Command"; +import { commandGroups } from "./groups"; + +export const logger = new Logger("Command Handler"); + +export async function handleCommand( + command: string, + args: string[], + prefix: string, + user: IUser +): Promise { + let foundCommand: Command | undefined; + + commandGroups.forEach(group => { + if (!foundCommand) { + foundCommand = group.commands.find(cmd => { + return cmd.aliases.includes(command); + }); + } + }); + + if (!foundCommand) return; + + // TODO Check user's (or their groups') permissions against command permission node + + try { + const response = await foundCommand.callback( + command, + args, + prefix, + user + ); + if (response) return { response }; + } catch (err) { + logger.error(err); + return { + response: + "An error has occurred, but no fish were lost. If you are the fishing bot owner, check the error logs for details." + }; + } +} diff --git a/src/api/commands/prefixes.ts b/src/api/commands/prefixes.ts new file mode 100644 index 0000000..04698cd --- /dev/null +++ b/src/api/commands/prefixes.ts @@ -0,0 +1 @@ +export const prefixes = ["/"]; diff --git a/src/api/data/token.ts b/src/api/data/token.ts new file mode 100644 index 0000000..a6a50c6 --- /dev/null +++ b/src/api/data/token.ts @@ -0,0 +1,27 @@ +import prisma from "./prisma"; + +export async function createToken() { + const randomToken = crypto.randomUUID(); + + await prisma.authToken.create({ + data: { + token: randomToken + } + }); + + return randomToken; +} + +export async function deleteToken(token: string) { + await prisma.authToken.delete({ + where: { token } + }); +} + +export async function checkToken(token: string) { + const existing = await prisma.authToken.findUnique({ + where: { token } + }); + + return !!existing; +} diff --git a/src/api/index.ts b/src/api/index.ts index 75462ab..fb0b736 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,2 +1 @@ import "./api/server"; -import prisma from "S:/data/prisma"; diff --git a/src/mpp/api/trpc.ts b/src/mpp/api/trpc.ts new file mode 100644 index 0000000..7a85c1b --- /dev/null +++ b/src/mpp/api/trpc.ts @@ -0,0 +1,15 @@ +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import type { AppRouter } from "@server/api/trpc"; + +export const trpc = createTRPCClient({ + links: [ + httpBatchLink({ + url: "http://localhost:3000" + }), + httpBatchLink({ + url: "https://fishing.hri7566.info/api" + }) + ] +}); + +export default trpc; diff --git a/src/mpp/bot/Bot.ts b/src/mpp/bot/Bot.ts new file mode 100644 index 0000000..be6a913 --- /dev/null +++ b/src/mpp/bot/Bot.ts @@ -0,0 +1,173 @@ +import Client from "mpp-client-net"; +import { Logger } from "@util/Logger"; +import trpc from "@client/api/trpc"; + +export interface MPPNetBotConfig { + uri: string; + + channel: { + id: string; + allowColorChanging: boolean; + }; +} + +export class MPPNetBot { + public client: Client; + + public logger: Logger; + + constructor( + public config: MPPNetBotConfig, + token: string = process.env[`MPP_TOKEN_NET`] as string + ) { + this.logger = new Logger(config.channel.id); + this.client = new Client(config.uri, token); + + this.client.setChannel(config.channel.id); + + this.bindEventListeners(); + } + + public start() { + this.client.start(); + } + + public stop() { + this.client.stop(); + } + + public bindEventListeners() { + this.client.on("hi", msg => { + this.logger.info(`Connected to ${this.client.uri}`); + }); + + this.client.on("ch", msg => { + this.logger.info( + `Received channel update for channel ID "${msg.ch._id}"` + ); + }); + + this.client.on("a", async msg => { + let prefixes: string[]; + + try { + prefixes = await trpc.prefixes.query(); + } catch (err) { + this.logger.error(err); + this.logger.warn("Unable to contact server"); + return; + } + + let usedPrefix: string | undefined = prefixes.find(pr => + msg.a.startsWith(pr) + ); + + if (!usedPrefix) return; + + const args = msg.a.split(" "); + + const command = await trpc.command.query({ + args: args.slice(1, args.length), + command: args[0].substring(usedPrefix.length), + prefix: usedPrefix, + user: { + id: msg.p._id, + name: msg.p.name, + color: msg.p.color + } + }); + + if (!command) return; + if (command.response) this.sendChat(command.response); + }); + + (this.client as unknown as any).on( + "dm", + async (msg: { + m: "dm"; + id: string; + t: number; + a: string; + + sender: { + _id: string; + name: string; + color: string; + afk: boolean; + tag?: { + text: string; + color: string; + }; + id: string; + }; + + recipient: { + _id: string; + name: string; + color: string; + afk: boolean; + tag?: { + text: string; + color: string; + }; + id: string; + }; + }) => { + let prefixes: string[]; + + try { + prefixes = await trpc.prefixes.query(); + } catch (err) { + this.logger.error(err); + this.logger.warn("Unable to contact server"); + return; + } + + let usedPrefix: string | undefined = prefixes.find(pr => + msg.a.startsWith(pr) + ); + + if (!usedPrefix) return; + + const args = msg.a.split(" "); + + const command = await trpc.command.query({ + args: args.slice(1, args.length), + command: args[0].substring(usedPrefix.length), + prefix: usedPrefix, + user: { + id: msg.sender._id, + name: msg.sender.name, + color: msg.sender.color + } + }); + + if (!command) return; + if (command.response) this.sendChat(command.response); + } + ); + } + + public sendChat(text: string) { + let lines = text.split("\n"); + + for (const line of lines) { + if (line.length <= 510) { + this.client.sendArray([ + { + m: "a", + message: `\u034f${line + .split("\t") + .join("") + .split("\r") + .join("")}` + } + ]); + } else { + this.sendChat(line); + } + } + } +} + +export default MPPNetBot; diff --git a/src/mpp/bot/index.ts b/src/mpp/bot/index.ts new file mode 100644 index 0000000..63f9800 --- /dev/null +++ b/src/mpp/bot/index.ts @@ -0,0 +1,30 @@ +import { loadConfig } from "@util/config"; +import { MPPNetBot, type MPPNetBotConfig } from "./Bot"; + +const bots = []; + +const defaults = loadConfig("config/bots.yml", [ + { + uri: "wss://mppclone.com:8443", + channel: { + id: "βœ§π““π“”π“₯ π“‘π“Έπ“Έπ“Άβœ§", + allowColorChanging: true + } + } +] as MPPNetBotConfig[]); + +export function connectDefaultBots() { + defaults.forEach(conf => { + initBot(conf); + }); +} + +export function initBot(conf: MPPNetBotConfig) { + const bot = new MPPNetBot(conf); + bot.start(); + bots.push(bot); +} + +export { MPPNetBot as Bot }; + +export default MPPNetBot; diff --git a/src/mpp/index.ts b/src/mpp/index.ts new file mode 100644 index 0000000..b5476de --- /dev/null +++ b/src/mpp/index.ts @@ -0,0 +1,3 @@ +import { connectDefaultBots } from "./bot"; + +connectDefaultBots(); diff --git a/src/util/Logger.ts b/src/util/Logger.ts new file mode 100644 index 0000000..82353ec --- /dev/null +++ b/src/util/Logger.ts @@ -0,0 +1,43 @@ +import { getHHMMSS } from "./time"; + +export class Logger { + private static log(...args: any[]) { + const time = getHHMMSS(); + + console.log(`\x1b[30m${time}\x1b[0m`, ...args); + } + + constructor(public id: string) {} + + public info(...args: any[]) { + Logger.log( + `\x1b[34m[${this.id}]\x1b[0m`, + `\x1b[34m[INFO]\x1b[0m`, + ...args + ); + } + + public error(...args: any[]) { + Logger.log( + `\x1b[34m[${this.id}]\x1b[0m`, + `\x1b[31m[ERROR]\x1b[0m`, + ...args + ); + } + + public warn(...args: any[]) { + Logger.log( + `\x1b[34m[${this.id}]\x1b[0m`, + `\x1b[33m[WARNING]\x1b[0m`, + ...args + ); + } + + public debug(...args: any[]) { + Logger.log( + `\x1b[34m[${this.id}]\x1b[0m`, + `\x1b[32m[DEBUG]\x1b[0m`, + ...args + ); + } +} diff --git a/src/util/config.ts b/src/util/config.ts new file mode 100644 index 0000000..4e3de3a --- /dev/null +++ b/src/util/config.ts @@ -0,0 +1,25 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import YAML from "yaml"; +import { parse } from "path/posix"; + +export function loadConfig(path: string, defaultConfig: T) { + const parsed = parse(path); + const dir = parsed.dir; + + if (!existsSync(dir)) { + mkdirSync(dir); + } + + if (existsSync(path)) { + const yaml = readFileSync(path).toString(); + const data = YAML.parse(yaml); + + return data as T; + } else { + return defaultConfig; + } +} + +export function saveConfig(path: string, config: T) { + writeFileSync(path, YAML.stringify(config)); +} diff --git a/src/util/tick.ts b/src/util/tick.ts new file mode 100644 index 0000000..25af37b --- /dev/null +++ b/src/util/tick.ts @@ -0,0 +1,19 @@ +type TickEvent = () => Promise | void; + +const ticks: TickEvent[] = []; + +(globalThis as unknown as Record).ticker = setInterval(() => { + for (const tick of ticks) tick(); +}, 1000 / 20); + +export function addTickEvent(event: TickEvent) { + ticks.push(event); +} + +export function removeTickEvent(event: TickEvent) { + const index = ticks.indexOf(event); + + if (index >= 0) { + ticks.splice(index, 1); + } +} diff --git a/src/util/time.ts b/src/util/time.ts new file mode 100644 index 0000000..7c7a907 --- /dev/null +++ b/src/util/time.ts @@ -0,0 +1,22 @@ +export function getHHMMSS() { + const now = Date.now(); + + const s = now / 1000; + const m = s / 60; + const h = m / 60; + + const hh = Math.floor(h % 12) + .toString() + .padStart(2, "0"); + const mm = Math.floor(m % 60) + .toString() + .padStart(2, "0"); + const ss = Math.floor(s % 60) + .toString() + .padStart(2, "0"); + const ms = Math.floor(now % 1000) + .toString() + .padStart(3, "0"); + + return `${hh}:${mm}:${ss}.${ms}`; +} diff --git a/src/util/types.d.ts b/src/util/types.d.ts new file mode 100644 index 0000000..736743b --- /dev/null +++ b/src/util/types.d.ts @@ -0,0 +1,16 @@ +interface IUser { + id: string; + name: string; + color: string; +} + +interface ICommandResponse { + response: string; +} + +type TCommandCallback = ( + command: string, + args: string[], + prefix: string, + user: IUser +) => Promise; diff --git a/test/api/data/token.test.ts b/test/api/data/token.test.ts new file mode 100644 index 0000000..7500b09 --- /dev/null +++ b/test/api/data/token.test.ts @@ -0,0 +1,28 @@ +import { checkToken, createToken, deleteToken } from "@server/data/token"; +import { test, expect } from "bun:test"; + +test("Token can be created and deleted", async () => { + const token = await createToken(); + expect(token).toBeString(); + await deleteToken(token); +}); + +test("Token can be validated", async () => { + const token = await createToken(); + expect(token).toBeString(); + + const checked = await checkToken(token); + expect(checked).toBeTruthy(); + + await deleteToken(token); +}); + +test("Token can be invalidated", async () => { + const token = await createToken(); + expect(token).toBeString(); + + await deleteToken(token); + + const checked = await checkToken(token); + expect(checked).toBeFalsy(); +}); diff --git a/tsconfig.json b/tsconfig.json index 8243fb1..7f0d096 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,28 @@ { - "compilerOptions": { - "lib": ["ESNext"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noFallthroughCasesInSwitch": true, - "forceConsistentCasingInFileNames": true, + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, - "paths": { - "S:/*": ["./src/api/*"], - "C:/*": ["./src/bot/*"], + "paths": { + "@server/*": ["./src/api/*"], + "@client/*": ["./src/mpp/*"], + "@util/*": ["./src/util/*"] + } } - } }