This commit is contained in:
Hri7566 2024-02-15 03:47:35 -05:00
parent 5112aa1d01
commit e447f2a355
26 changed files with 662 additions and 48 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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;

View File

@ -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<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;
})
});

View File

@ -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;

View File

@ -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";
}
);

View File

@ -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")}`;
}
);

View File

@ -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);

View File

@ -0,0 +1,10 @@
import Command from "@server/commands/Command";
export const data = new Command(
"data",
["data"],
"Data command",
"data",
"command.util.data",
async () => {}
);

View File

@ -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<ICommandResponse | void> {
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."
};
}
}

View File

@ -0,0 +1 @@
export const prefixes = ["/"];

27
src/api/data/token.ts Normal file
View File

@ -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;
}

View File

@ -1,2 +1 @@
import "./api/server";
import prisma from "S:/data/prisma";

15
src/mpp/api/trpc.ts Normal file
View File

@ -0,0 +1,15 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@server/api/trpc";
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3000"
}),
httpBatchLink({
url: "https://fishing.hri7566.info/api"
})
]
});
export default trpc;

173
src/mpp/bot/Bot.ts Normal file
View File

@ -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;

30
src/mpp/bot/index.ts Normal file
View File

@ -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;

3
src/mpp/index.ts Normal file
View File

@ -0,0 +1,3 @@
import { connectDefaultBots } from "./bot";
connectDefaultBots();

43
src/util/Logger.ts Normal file
View File

@ -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
);
}
}

25
src/util/config.ts Normal file
View File

@ -0,0 +1,25 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
import YAML from "yaml";
import { parse } from "path/posix";
export function loadConfig<T>(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<T>(path: string, config: T) {
writeFileSync(path, YAML.stringify(config));
}

19
src/util/tick.ts Normal file
View File

@ -0,0 +1,19 @@
type TickEvent = () => Promise<void> | void;
const ticks: TickEvent[] = [];
(globalThis as unknown as Record<string, unknown>).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);
}
}

22
src/util/time.ts Normal file
View File

@ -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}`;
}

16
src/util/types.d.ts vendored Normal file
View File

@ -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<string | void>;

View File

@ -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();
});

View File

@ -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/*"]
}
}
}
}