Implement user and inventory, back messages, and color changing, add pokedex

This commit is contained in:
Hri7566 2024-02-22 08:10:24 -05:00
parent 11967df2ae
commit 29676fb502
21 changed files with 29520 additions and 43 deletions

16586
config/pokedex.json Normal file

File diff suppressed because it is too large Load Diff

12540
config/pokedex.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ model User {
model Inventory { model Inventory {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
balance Int balance Int @default(0)
items Json @default("[]") items Json @default("[]")
fishSack Json @default("[]") fishSack Json @default("[]")

36
scripts/jsonyaml.ts Normal file
View File

@ -0,0 +1,36 @@
import { Logger } from "@util/Logger";
import { argv } from "bun";
import { existsSync, readFileSync, writeFileSync } from "fs";
import YAML from "yaml";
const logger = new Logger("JSON Converter");
const inFile = argv[2];
const outFile = argv[3];
if (typeof inFile !== "string" || typeof outFile !== "string") {
logger.error(`Usage: <infile> <outfile>`);
process.exit();
}
if (!existsSync(inFile)) {
logger.error("Input file not found");
process.exit();
}
logger.info("Reading JSON...");
let data: unknown;
try {
const jdata = readFileSync(inFile).toString();
data = JSON.parse(jdata);
} catch (err) {
logger.error(err);
logger.error("JSON read error");
process.exit();
}
logger.info("Writing file...");
writeFileSync(outFile, YAML.stringify(data));
logger.info("Done.");

View File

@ -1,6 +1,7 @@
import { getBacks, flushBacks } from "@server/backs";
import { handleCommand } from "@server/commands/handler"; import { handleCommand } from "@server/commands/handler";
import { prefixes } from "@server/commands/prefixes"; import { prefixes } from "@server/commands/prefixes";
import { checkToken } from "@server/data/token"; import { checkToken, tokenToID } from "@server/data/token";
import { TRPCError, initTRPC } from "@trpc/server"; import { TRPCError, initTRPC } from "@trpc/server";
import { Logger } from "@util/Logger"; import { Logger } from "@util/Logger";
import type { CreateBunContextOptions } from "trpc-bun-adapter"; import type { CreateBunContextOptions } from "trpc-bun-adapter";
@ -8,11 +9,21 @@ import { z } from "zod";
const logger = new Logger("tRPC"); const logger = new Logger("tRPC");
interface FishingContext {
isAuthed: boolean;
req: Request;
token: string | null;
}
interface AuthedFishingContext extends FishingContext {
token: string;
}
export const createContext = async (opts: CreateBunContextOptions) => { export const createContext = async (opts: CreateBunContextOptions) => {
return { return {
isAuthed: false, isAuthed: false,
req: opts.req req: opts.req
}; } as FishingContext;
}; };
export type Context = Awaited<ReturnType<typeof createContext>>; export type Context = Awaited<ReturnType<typeof createContext>>;
@ -25,14 +36,15 @@ export const privateProcedure = publicProcedure.use(async opts => {
const { ctx } = opts; const { ctx } = opts;
const { req } = ctx; const { req } = ctx;
const token = req.headers.get("authorization"); opts.ctx.token = req.headers.get("authorization");
if (!token) throw new TRPCError({ code: "UNAUTHORIZED" }); if (!opts.ctx.token) throw new TRPCError({ code: "UNAUTHORIZED" });
opts.ctx.isAuthed = await checkToken(opts.ctx.token);
opts.ctx.isAuthed = await checkToken(token);
if (!ctx.isAuthed) throw new TRPCError({ code: "UNAUTHORIZED" }); if (!ctx.isAuthed) throw new TRPCError({ code: "UNAUTHORIZED" });
return opts.next(opts); return opts.next({
ctx: opts.ctx as AuthedFishingContext
});
}); });
export const appRouter = router({ export const appRouter = router({
@ -54,11 +66,21 @@ export const appRouter = router({
}) })
) )
.query(async opts => { .query(async opts => {
const id = tokenToID(opts.ctx.token);
const { command, args, prefix, user } = opts.input; const { command, args, prefix, user } = opts.input;
const out = await handleCommand(command, args, prefix, user); const out = await handleCommand(id, command, args, prefix, user);
return out; return out;
}) }),
backs: privateProcedure.query(async opts => {
const id = tokenToID(opts.ctx.token);
const backs = getBacks<{}>(id);
flushBacks(id);
return backs;
})
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

20
src/api/backs/index.ts Normal file
View File

@ -0,0 +1,20 @@
export const backs: Record<string, IBack<unknown>[]> = {};
export function flushBacks<T>(id: string) {
backs[id] = [];
}
export function addBack<T>(id: string, back: IBack<T>) {
if (!backs[id]) backs[id] = [];
backs[id].push(back);
}
export function hasBack<T>(id: string, back: IBack<T>) {
if (!backs[id]) return false;
if (backs[id].includes(back)) return true;
}
export function getBacks<T>(id: string) {
if (!backs[id]) return [];
return backs[id] as T;
}

View File

@ -1,3 +1,5 @@
import type { User } from "@prisma/client";
export class Command { export class Command {
constructor( constructor(
public id: string, public id: string,
@ -5,7 +7,7 @@ export class Command {
public description: string, public description: string,
public usage: string, public usage: string,
public permissionNode: string, public permissionNode: string,
public callback: TCommandCallback, public callback: TCommandCallback<User>,
public visible: boolean = true public visible: boolean = true
) {} ) {}
} }

View File

@ -6,7 +6,7 @@ export const fish = new Command(
"Send your LURE into a water for catching fish", "Send your LURE into a water for catching fish",
"fish", "fish",
"command.fishing.fish", "command.fishing.fish",
async () => { async ({ id, command, args, prefix, part, user }) => {
return "There is no fishing yet, please come back later when I write the code for it"; return "There is no fishing yet, please come back later when I write the code for it";
} }
); );

View File

@ -18,17 +18,30 @@ export const help = new Command(
"cammands", "cammands",
"cummunds" "cummunds"
], ],
"Help command", "Get command list or command usage",
"help [command]", "help [command]",
"command.general.help", "command.general.help",
async (command, args, prefix, user) => { async ({ id, command, args, prefix, part, user }) => {
return `${commandGroups if (!args[0]) {
.map( return `${commandGroups
group => .map(
`${group.displayName}: ${group.commands group =>
.map(cmd => (cmd.visible ? cmd.aliases[0] : "<hidden>")) `${group.displayName}: ${group.commands
.join(", ")}` .map(cmd =>
) cmd.visible ? cmd.aliases[0] : "<hidden>"
.join("\n")}`; )
.join(", ")}`
)
.join("\n")}`;
} else {
const commands = commandGroups.flatMap(group => group.commands);
const foundCommand = commands.find(cmd =>
cmd.aliases.includes(args[0])
);
if (!foundCommand) return `Command "${args[0]}" not found.`;
return `Description: ${foundCommand.description} | Usage: ${foundCommand.usage}`;
}
} }
); );

View File

@ -1,6 +1,7 @@
import type { Command } from "../Command"; import type { Command } from "../Command";
import { fish } from "./fishing/fish"; import { fish } from "./fishing/fish";
import { help } from "./general/help"; import { help } from "./general/help";
import { setcolor } from "./util/setcolor";
import { data } from "./util/data"; import { data } from "./util/data";
interface ICommandGroup { interface ICommandGroup {
@ -30,7 +31,7 @@ commandGroups.push(fishing);
const util: ICommandGroup = { const util: ICommandGroup = {
id: "util", id: "util",
displayName: "Utility", displayName: "Utility",
commands: [data] commands: [data, setcolor]
}; };
commandGroups.push(util); commandGroups.push(util);

View File

@ -6,7 +6,7 @@ export const data = new Command(
"Data command", "Data command",
"data", "data",
"command.util.data", "command.util.data",
async (command, args, prefix, user) => { async props => {
return JSON.stringify({ command, args, prefix, user }); return JSON.stringify(props);
} }
); );

View File

@ -0,0 +1,21 @@
import { addBack } from "@server/backs";
import Command from "@server/commands/Command";
export const setcolor = new Command(
"setcolor",
["setcolor"],
"Set own user color",
"setcolor <color>",
"command.util.setcolor",
async ({ id, command, args, prefix, part, user }) => {
if (typeof args[0] !== "string") return "Please provide a color.";
addBack(id, {
m: "color",
id: part.id,
color: args[0]
});
return "Attempting to set color.";
}
);

View File

@ -1,14 +1,17 @@
import { Logger } from "@util/Logger"; import { Logger } from "@util/Logger";
import type Command from "./Command"; import type Command from "./Command";
import { commandGroups } from "./groups"; import { commandGroups } from "./groups";
import { createUser, getUser } from "@server/data/user";
import { createInventory, getInventory } from "@server/data/inventory";
export const logger = new Logger("Command Handler"); export const logger = new Logger("Command Handler");
export async function handleCommand( export async function handleCommand(
id: string,
command: string, command: string,
args: string[], args: string[],
prefix: string, prefix: string,
user: IUser part: IPart
): Promise<ICommandResponse | void> { ): Promise<ICommandResponse | void> {
let foundCommand: Command | undefined; let foundCommand: Command | undefined;
@ -22,21 +25,42 @@ export async function handleCommand(
if (!foundCommand) return; if (!foundCommand) return;
let user = await getUser(part.id);
if (!user) {
const inventory = await createInventory({});
user = await createUser({
id: part.id,
name: part.name,
color: part.color,
inventoryId: inventory.id
});
}
let inventory = await getInventory(user.inventoryId);
if (!inventory) inventory = await createInventory({ id: user.inventoryId });
// TODO Check user's (or their groups') permissions against command permission node // TODO Check user's (or their groups') permissions against command permission node
try { try {
const response = await foundCommand.callback( const response = await foundCommand.callback({
id,
command, command,
args, args,
prefix, prefix,
part,
user user
); });
if (response) return { response }; if (response) return { response };
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
return { return {
response: response:
"An error has occurred, but no fish were lost. If you are the fishing bot owner, check the error logs for details." "An error has occurred, but no fish were lost. If you are the fishing bot owner, check the server's error logs for details."
}; };
} }
} }

26
src/api/data/inventory.ts Normal file
View File

@ -0,0 +1,26 @@
import prisma from "./prisma";
export async function createInventory(inventory: Partial<IInventory>) {
return await prisma.inventory.create({
data: inventory
});
}
export async function getInventory(id: IInventory["id"]) {
return await prisma.inventory.findUnique({
where: { id }
});
}
export async function updateInventory(inventory: Partial<IInventory>) {
return await prisma.inventory.update({
where: { id: inventory.id },
data: inventory
});
}
export async function deleteInventory(id: IInventory["id"]) {
return await prisma.inventory.delete({
where: { id }
});
}

View File

@ -1,4 +1,5 @@
import prisma from "./prisma"; import prisma from "./prisma";
import { createHash } from "crypto";
export async function createToken() { export async function createToken() {
const randomToken = crypto.randomUUID(); const randomToken = crypto.randomUUID();
@ -29,3 +30,10 @@ export async function checkToken(token: string) {
export async function getAllTokens() { export async function getAllTokens() {
return await prisma.authToken.findMany(); return await prisma.authToken.findMany();
} }
export function tokenToID(token: string) {
const hash = createHash("sha-256");
hash.update("ID");
hash.update(token);
return hash.digest("hex").substring(0, 24);
}

31
src/api/data/user.ts Normal file
View File

@ -0,0 +1,31 @@
import type { User } from "@prisma/client";
import prisma from "./prisma";
export async function createUser(user: User) {
return await prisma.user.create({
data: user
});
}
export async function getUser(id: string) {
const user = await prisma.user.findUnique({
where: { id }
});
return user;
}
export async function updateUser(user: Partial<User> & { id: User["id"] }) {
return await prisma.user.update({
where: {
id: user.id
},
data: user
});
}
export async function deleteUser(id: string) {
return await prisma.user.delete({
where: { id }
});
}

View File

@ -1,6 +1,7 @@
import Client from "mpp-client-net"; import Client from "mpp-client-net";
import { Logger } from "@util/Logger"; import { Logger } from "@util/Logger";
import trpc from "@client/api/trpc"; import trpc from "@client/api/trpc";
import { EventEmitter } from "events";
export interface MPPNetBotConfig { export interface MPPNetBotConfig {
uri: string; uri: string;
@ -13,7 +14,7 @@ export interface MPPNetBotConfig {
export class MPPNetBot { export class MPPNetBot {
public client: Client; public client: Client;
public b = new EventEmitter();
public logger: Logger; public logger: Logger;
constructor( constructor(
@ -78,7 +79,8 @@ export class MPPNetBot {
}); });
if (!command) return; if (!command) return;
if (command.response) this.sendChat(command.response); if (command.response)
this.sendChat(command.response, (msg as any).id);
}); });
(this.client as unknown as any).on( (this.client as unknown as any).on(
@ -143,24 +145,52 @@ export class MPPNetBot {
}); });
if (!command) return; if (!command) return;
if (command.response) this.sendChat(command.response); if (command.response) this.sendChat(command.response, msg.id);
} }
); );
setInterval(async () => {
try {
const backs = (await trpc.backs.query()) as IBack<unknown>[];
if (backs.length > 0) {
this.logger.debug(backs);
for (const back of backs) {
if (typeof back.m !== "string") return;
this.b.emit(back.m, back);
}
}
} catch (err) {
return;
}
}, 1000 / 20);
this.b.on("color", msg => {
if (typeof msg.color !== "string" || typeof msg.id !== "string")
return;
this.client.sendArray([
{
m: "setcolor",
_id: msg.id,
color: msg.color
}
]);
});
} }
public sendChat(text: string) { public sendChat(text: string, reply?: string) {
let lines = text.split("\n"); let lines = text.split("\n");
for (const line of lines) { for (const line of lines) {
if (line.length <= 510) { if (line.length <= 510) {
this.client.sendArray([ (this.client as any).sendArray([
{ {
m: "a", m: "a",
message: `\u034f${line message: `\u034f${line
.split("\t") .split("\t")
.join("") .join("")
.split("\r") .split("\r")
.join("")}` .join("")}`,
reply_to: reply
} }
]); ]);
} else { } else {

65
src/util/types.d.ts vendored
View File

@ -1,4 +1,4 @@
interface IUser { interface IPart {
id: string; id: string;
name: string; name: string;
color: string; color: string;
@ -8,9 +8,60 @@ interface ICommandResponse {
response: string; response: string;
} }
type TCommandCallback = ( type TCommandCallback<User> = (props: {
command: string, id: string;
args: string[], command: string;
prefix: string, args: string[];
user: IUser prefix: string;
) => Promise<string | void>; part: IPart;
user: User;
}) => Promise<string | void>;
interface CountComponent {
count: number;
}
interface IFish extends JsonValue {
id: string;
name: string;
size: string;
}
interface IPokemon extends JsonValue {
id: number;
name: {
english: string;
japanese: string;
chinese: string;
french: string;
};
type: string[];
base: {
HP: number;
Attack: number;
Defense: number;
"Sp. Attack": number;
"Sp. Defense": number;
Speed: number;
};
}
type TFishSack = JsonArray & IFish[];
type TPokemonSack = JsonArray & IPokemon[];
interface IInventory {
id: number;
balance: number;
fishSack: TFishSack;
pokemon: TPokemonSack;
}
interface IBack<T extends string | unknown> extends Record<string, unknown> {
m: T;
}
interface Backs extends Record<string, IBack<unknown>> {
color: {
m: "color";
};
}

View File

@ -0,0 +1,16 @@
import {
createInventory,
deleteInventory,
getInventory
} from "@server/data/inventory";
import { test, expect } from "bun:test";
test("Inventory can be created, read, updated and deleted", async () => {
const inventory = await createInventory({});
expect(inventory.id).toBeNumber();
await deleteInventory(inventory.id);
const badInventory = await getInventory(inventory.id);
expect(badInventory).toBeNull();
});

View File

@ -1,4 +1,9 @@
import { checkToken, createToken, deleteToken } from "@server/data/token"; import {
checkToken,
createToken,
deleteToken,
tokenToID
} from "@server/data/token";
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
test("Token can be created and deleted", async () => { test("Token can be created and deleted", async () => {
@ -26,3 +31,14 @@ test("Token can be invalidated", async () => {
const checked = await checkToken(token); const checked = await checkToken(token);
expect(checked).toBeFalsy(); expect(checked).toBeFalsy();
}); });
test("Token can be digested into ID", async () => {
const token = await createToken();
expect(token).toBeString();
const id = tokenToID(token);
expect(id).toBeString();
expect(id).toHaveLength(24);
await deleteToken(token);
});

View File

@ -0,0 +1,34 @@
import type { User } from "@prisma/client";
import { createInventory } from "@server/data/inventory";
import { createUser, getUser, updateUser, deleteUser } from "@server/data/user";
import { test, expect } from "bun:test";
test("User can be created, read, updated, and deleted", async () => {
const inventory = await createInventory({});
const data = {
id: "test",
name: "test",
color: "#8d3f50",
inventoryId: inventory.id
};
await createUser(data);
const user = await getUser(data.id);
expect(user).toBeDefined();
expect(user?.id).toBeString();
expect(user?.name).toBeString();
await updateUser({
id: data.id,
name: "hi"
});
const user2 = await getUser(data.id);
expect(user2).toBeDefined();
expect(user2?.name).toBeString();
await deleteUser((user as User).id);
});