Compare commits

..

7 Commits

Author SHA1 Message Date
Hri7566 3994368f3c Comments 2023-10-25 00:01:28 -04:00
Hri7566 16b4a93e72 Add missing user flags 2023-10-25 00:01:09 -04:00
Hri7566 645cffb4ec Config loader that writes default configs 2023-10-25 00:00:59 -04:00
Hri7566 7d25ea57bf Export channel settings 2023-10-25 00:00:30 -04:00
Hri7566 6c39c3405b Organize Channel.ts, add comments, and implement crown 2023-10-25 00:00:20 -04:00
Hri7566 943fb1fbcc Comments 2023-10-24 23:59:01 -04:00
Hri7566 6a1577fc7e Update packages 2023-10-24 23:58:44 -04:00
10 changed files with 375 additions and 90 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -1,34 +1,34 @@
{
"name": "mpp-server",
"version": "2.0.0",
"description": "Hri7566's MPP Server",
"main": "src/index.ts",
"scripts": {
"start": "bun .",
"build": "bun build ./src/index.ts --outdir=out",
"dev": "bun run src/index.ts"
},
"keywords": [],
"author": "Hri7566",
"license": "ISC",
"dependencies": {
"@prisma/client": "5.2.0",
"@t3-oss/env-core": "^0.6.1",
"bun": "^1.0.0",
"bun-types": "^1.0.1",
"date-holidays": "^3.21.5",
"dotenv": "^8.6.0",
"events": "^3.3.0",
"fancy-text-converter": "^1.0.9",
"keccak": "^2.1.0",
"mppclone-client": "^1.1.3",
"unique-names-generator": "^4.7.1",
"yaml": "^2.3.2",
"zod": "^3.22.2"
},
"devDependencies": {
"@types/node": "^20.5.9",
"prisma": "^5.2.0",
"typescript": "^5.2.2"
}
"name": "mpp-server",
"version": "2.0.0",
"description": "Hri7566's MPP Server",
"main": "src/index.ts",
"scripts": {
"start": "bun .",
"build": "bun build ./src/index.ts --outdir=out",
"dev": "bun run src/index.ts"
},
"keywords": [],
"author": "Hri7566",
"license": "ISC",
"dependencies": {
"@prisma/client": "5.2.0",
"@t3-oss/env-core": "^0.6.1",
"bun": "^1.0.0",
"bun-types": "^1.0.1",
"date-holidays": "^3.21.5",
"dotenv": "^8.6.0",
"events": "^3.3.0",
"fancy-text-converter": "^1.0.9",
"keccak": "^2.1.0",
"mppclone-client": "^1.1.3",
"unique-names-generator": "^4.7.1",
"yaml": "^2.3.2",
"zod": "^3.22.2"
},
"devDependencies": {
"@types/node": "^20.5.9",
"prisma": "^5.2.0",
"typescript": "^5.2.2"
}
}

View File

@ -11,6 +11,7 @@ import {
import { Socket } from "../ws/Socket";
import { validateChannelSettings } from "./settings";
import { socketsBySocketID } from "../ws/server";
import Crown from "./Crown";
interface ChannelConfig {
forceLoad: string[];
@ -54,8 +55,14 @@ export class Channel extends EventEmitter {
public chatHistory = new Array<ClientEvents["a"]>();
// TODO Add the crown
public crown?: Crown;
constructor(private _id: string, set?: Partial<ChannelSettings>) {
constructor(
private _id: string,
set?: Partial<ChannelSettings>,
creator?: Socket,
owner_id?: string
) {
super();
this.logger = new Logger("Channel - " + _id);
@ -63,13 +70,24 @@ export class Channel extends EventEmitter {
// Validate settings in set
// Set the verified settings
if (set && !this.isLobby()) {
const validatedSet = validateChannelSettings(set);
if (!this.isLobby()) {
if (set) {
const validatedSet = validateChannelSettings(set);
for (const key in Object.keys(validatedSet)) {
if (!(validatedSet as any)[key]) continue;
for (const key in Object.keys(validatedSet)) {
if (!(validatedSet as any)[key]) continue;
(this.settings as any)[key] = (set as any)[key];
(this.settings as any)[key] = (set as any)[key];
}
}
this.crown = new Crown();
if (creator) {
if (this.crown.canBeSetBy(creator)) {
const part = creator.getParticipant();
if (part) this.giveCrown(part);
}
}
}
@ -83,10 +101,79 @@ export class Channel extends EventEmitter {
// TODO channel closing
}
private alreadyBound = false;
private bindEventListeners() {
if (this.alreadyBound) return;
this.alreadyBound = true;
this.on("update", () => {
// Send updated info
for (const socket of socketsBySocketID.values()) {
for (const p of this.ppl) {
if (socket.getParticipantID() == p.id) {
socket.sendChannelUpdate(
this.getInfo(),
this.getParticipantList()
);
}
}
}
if (this.ppl.length == 0) {
this.destroy();
}
});
this.on("message", (msg: ServerEvents["a"], socket: Socket) => {
if (!msg.message) return;
const userFlags = socket.getUserFlags();
this.logger.debug(userFlags);
if (userFlags) {
if (userFlags.cant_chat) return;
}
// Sanitize
msg.message = msg.message
.replace(/\p{C}+/gu, "")
.replace(/(\p{Mc}{5})\p{Mc}+/gu, "$1")
.trim();
let outgoing: ClientEvents["a"] = {
m: "a",
a: msg.message,
t: Date.now(),
p: socket.getParticipant() as Participant
};
this.sendArray([outgoing]);
this.chatHistory.push(outgoing);
try {
if (msg.message.startsWith("/")) {
this.emit("command", msg, socket);
}
} catch (err) {
this.logger.debug(err);
}
});
}
/**
* Get this channel's ID (channel name)
* @returns Channel ID
*/
public getID() {
return this._id;
}
/**
* Determine whether this channel is a lobby (uses regex from config)
* @returns Boolean
*/
public isLobby() {
for (const reg of config.lobbyRegexes) {
let exp = new RegExp(reg, "g");
@ -99,6 +186,12 @@ export class Channel extends EventEmitter {
return false;
}
/**
* Change this channel's settings
* @param set Channel settings
* @param admin Whether a user is changing the settings (set to true to force the changes)
* @returns undefined
*/
public changeSettings(
set: Partial<ChannelSettings>,
admin: boolean = false
@ -125,6 +218,20 @@ export class Channel extends EventEmitter {
}
}
/**
* Get a channel setting's value
* @param setting Channel setting to get
* @returns Value of setting
*/
public getSetting(setting: keyof ChannelSettings) {
return this.settings[setting];
}
/**
* Make a socket join this channel
* @param socket Socket that is joining
* @returns undefined
*/
public join(socket: Socket) {
if (this.isDestroyed()) return;
const part = socket.getParticipant() as Participant;
@ -200,6 +307,10 @@ export class Channel extends EventEmitter {
]);
}
/**
* Make a socket leave this channel
* @param socket Socket that is leaving
*/
public leave(socket: Socket) {
// this.logger.debug("Leave called");
const part = socket.getParticipant() as Participant;
@ -234,6 +345,10 @@ export class Channel extends EventEmitter {
this.emit("update");
}
/**
* Determine whether this channel has too many users
* @returns Boolean
*/
public isFull() {
// TODO Use limit setting
@ -244,29 +359,52 @@ export class Channel extends EventEmitter {
return false;
}
/**
* Get this channel's information
* @returns Channel info object (includes ID, number of users, settings, and the crown)
*/
public getInfo() {
return {
_id: this.getID(),
id: this.getID(),
count: this.ppl.length,
settings: this.settings
settings: this.settings,
crown: JSON.parse(JSON.stringify(this.crown))
};
}
/**
* Get the people in this channel
* @returns List of people
*/
public getParticipantList() {
return this.ppl;
}
/**
* Determine whether a user is in this channel (by user ID)
* @param _id User ID
* @returns Boolean
*/
public hasUser(_id: string) {
const foundPart = this.ppl.find(p => p._id == _id);
return !!foundPart;
}
/**
* Determine whether a user is in this channel (by participant ID)
* @param id Participant ID
* @returns Boolean
*/
public hasParticipant(id: string) {
const foundPart = this.ppl.find(p => p.id == id);
return !!foundPart;
}
/**
* Send messages to everyone in this channel
* @param arr List of events to send to clients
*/
public sendArray<EventID extends keyof ClientEvents>(
arr: ClientEvents[EventID][]
) {
@ -284,45 +422,12 @@ export class Channel extends EventEmitter {
}
}
private alreadyBound = false;
private bindEventListeners() {
if (this.alreadyBound) return;
this.alreadyBound = true;
this.on("update", () => {
// Send updated info
for (const socket of socketsBySocketID.values()) {
for (const p of this.ppl) {
if (socket.getParticipantID() == p.id) {
socket.sendChannelUpdate(
this.getInfo(),
this.getParticipantList()
);
}
}
}
if (this.ppl.length == 0) {
this.destroy();
}
});
this.on("message", (msg: ServerEvents["a"], socket: Socket) => {
if (!msg.message) return;
let outgoing: ClientEvents["a"] = {
m: "a",
a: msg.message,
t: Date.now(),
p: socket.getParticipant() as Participant
};
this.sendArray([outgoing]);
this.chatHistory.push(outgoing);
});
}
/**
* Play notes (usually from a socket)
* @param msg Note message
* @param socket Socket that is sending notes
* @returns undefined
*/
public playNotes(msg: ServerEvents["n"], socket: Socket) {
if (this.isDestroyed()) return;
const part = socket.getParticipant();
@ -352,6 +457,10 @@ export class Channel extends EventEmitter {
private destroyed = false;
/**
* Set this channel to the destroyed state
* @returns undefined
*/
public destroy() {
if (this.destroyed) return;
this.destroyed = true;
@ -366,12 +475,61 @@ export class Channel extends EventEmitter {
channelList.splice(channelList.indexOf(this), 1);
}
/**
* Determine whether the channel is in a destroyed state
* @returns Boolean
*/
public isDestroyed() {
return this.destroyed == true;
}
/**
* Change ownership (don't forget to use crown.canBeSetBy if you're letting a user call this)
* @param part Participant to give crown to (or undefined to drop crown)
*/
public chown(part?: Participant) {
if (this.crown) {
if (part) {
this.giveCrown(part);
} else {
this.dropCrown();
}
}
}
/**
* Give the crown to a user (no matter what)
* @param part Participant to give crown to
* @param force Whether or not to force-create a crown (useful for lobbies)
*/
public giveCrown(part: Participant, force?: boolean) {
if (force) {
if (!this.crown) this.crown = new Crown();
}
if (this.crown) {
this.crown.userId = part._id;
this.crown.participantId = part.id;
this.crown.time = Date.now();
this.emit("update");
}
}
/**
* Drop the crown (remove from user)
*/
public dropCrown() {
if (this.crown) {
delete this.crown.participantId;
this.crown.time = Date.now();
this.emit("update");
}
}
}
// Forceloader
export default Channel;
// Channel forceloader (cringe)
let hasFullChannel = false;
for (const id of config.forceLoad) {

41
src/channel/Crown.ts Normal file
View File

@ -0,0 +1,41 @@
import { Participant } from "../util/types";
import { Socket } from "../ws/Socket";
export class Crown {
public userId: string | undefined;
public participantId: string | undefined;
public time: number = Date.now();
public canBeSetBy(socket: Socket) {
// can claim, drop, or give if...
const flags = socket.getUserFlags();
if (!flags) return false;
if (flags.cansetcrowns) return true;
const channel = socket.getCurrentChannel();
if (!channel) return false;
const part = socket.getParticipant();
if (!part) return false;
if (!channel.getSetting("lobby")) {
// if there is no user (never been owned)
if (!this.userId) return true;
// if you're the user (you dropped it or left the room, nobody has claimed it)
if (this.userId === part._id) return true;
// if there is no participant and 15 seconds have passed
if (!this.participantId && this.time + 15000 < Date.now())
return true;
// you're the specially designated channel owner
if (channel.getSetting("owner_id") === part._id) return true;
}
return false;
}
}
export default Crown;

7
src/channel/index.ts Normal file
View File

@ -0,0 +1,7 @@
import Channel from "./Channel";
import Crown from "./Crown";
import validateChannelSettings, {
validate as validateChannelSetting
} from "./settings";
export { Channel, Crown, validateChannelSettings, validateChannelSetting };

View File

@ -52,7 +52,9 @@ export function validateChannelSettings(set: Partial<ChannelSettings>) {
return record;
}
function validate(value: any, validator: Validator) {
export default validateChannelSettings;
export function validate(value: any, validator: Validator) {
// What type of validator?
if (typeof validator == "function") {
// We are copying Zod's functionality

View File

@ -1,4 +1,12 @@
/**
* MPP Server 2
* for mpp.dev
* by Hri7566
*/
// Preload environment variables
import env from "./util/env";
// import { app } from "./ws/server";
import "./ws/server";
import { Logger } from "./util/Logger";

View File

@ -1,13 +1,73 @@
import YAML from "yaml";
import fs from "fs";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { parse, stringify } from "yaml";
import { z } from "zod";
export function loadConfig<T>(filepath: string, def: T) {
try {
const data = fs.readFileSync(filepath).toString();
const parsed = YAML.parse(data);
return parsed as T;
} catch (err) {
console.error("Unable to load config:", err);
return def;
/**
* 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<T>(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<string, any>;
let changed = false;
function mix(
obj: Record<string, unknown>,
obj2: Record<string, unknown>
) {
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<string, unknown>,
obj2[key] as Record<string, unknown>
);
}
}
}
// 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<T>(configPath: string, config: T) {
writeFileSync(
configPath,
stringify(config, {
indent: 4
})
);
}

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

@ -9,6 +9,8 @@ declare type UserFlags = Partial<{
chat_curse_2: number;
override_id: string;
volume: number;
cant_chat: number;
cansetcrowns: number;
}>;
declare interface Tag {

View File

@ -1,3 +1,9 @@
/**
* Socket connection module
*
* Represents user connections
*/
import { createColor, createID, createUserID } from "../util/id";
import EventEmitter from "events";
import {
@ -303,6 +309,7 @@ export class Socket extends EventEmitter {
public async userset(name?: string, color?: string) {
let isColor = false;
// Color changing
if (color && config.enableColorChanging) {
isColor =
typeof color === "string" && !!color.match(/^#[0-9a-f]{6}$/i);