format code with prettier

This commit is contained in:
Elijah R 2024-07-14 15:26:05 -04:00
parent cc2a9db92e
commit 1ad9ee14fe
30 changed files with 1224 additions and 1178 deletions

View file

@ -115,14 +115,14 @@ class AgentWordBalloonState {
positionUpdated() { positionUpdated() {
let size = this.char.getSize(); let size = this.char.getSize();
this.balloonCanvas.style.left = -((this.balloonCanvas.width / 2) - (size.w / 2)) + 'px'; this.balloonCanvas.style.left = -(this.balloonCanvas.width / 2 - size.w / 2) + 'px';
switch (this.position) { switch (this.position) {
case AgentWordBalloonPosition.AboveCentered: { case AgentWordBalloonPosition.AboveCentered: {
this.balloonCanvas.style.top = -(this.balloonCanvas.height) + 'px'; this.balloonCanvas.style.top = -this.balloonCanvas.height + 'px';
break; break;
} }
case AgentWordBalloonPosition.BelowCentered: { case AgentWordBalloonPosition.BelowCentered: {
this.balloonCanvas.style.bottom = -(this.balloonCanvas.height) + 'px'; this.balloonCanvas.style.bottom = -this.balloonCanvas.height + 'px';
break; break;
} }
} }
@ -307,8 +307,8 @@ export class Agent {
if (this.wordballoonState != null) { if (this.wordballoonState != null) {
this.stopSpeaking(); this.stopSpeaking();
} }
this.wordballoonState = new AgentWordBalloonState(this, text, true, AgentWordBalloonPosition.AboveCentered, "#000000"); this.wordballoonState = new AgentWordBalloonState(this, text, true, AgentWordBalloonPosition.AboveCentered, '#000000');
this.wordballoonState.positionUpdated(); this.wordballoonState.positionUpdated();
this.wordballoonState.show(); this.wordballoonState.show();
} }

View file

@ -5,21 +5,21 @@ export enum SeekDir {
BEG = 0, BEG = 0,
CUR = 1, CUR = 1,
END = 2 END = 2
}; }
// A helper over DataView to make it more ergonomic for parsing file data. // A helper over DataView to make it more ergonomic for parsing file data.
export class BufferStream { export class BufferStream {
private bufferImpl: Uint8Array; private bufferImpl: Uint8Array;
private dataView: DataView; private dataView: DataView;
private readPointer: number = 0; private readPointer: number = 0;
constructor(buffer: Uint8Array, byteOffset?: number) { constructor(buffer: Uint8Array, byteOffset?: number) {
this.bufferImpl = buffer; this.bufferImpl = buffer;
this.dataView = new DataView(this.bufferImpl.buffer, byteOffset); this.dataView = new DataView(this.bufferImpl.buffer, byteOffset);
} }
seek(where: number, whence: SeekDir) { seek(where: number, whence: SeekDir) {
switch(whence) { switch (whence) {
case SeekDir.BEG: case SeekDir.BEG:
this.readPointer = where; this.readPointer = where;
break; break;
@ -29,8 +29,7 @@ export class BufferStream {
break; break;
case SeekDir.END: case SeekDir.END:
if(where > 0) if (where > 0) throw new Error('Cannot use SeekDir.END with where greater than 0');
throw new Error("Cannot use SeekDir.END with where greater than 0");
this.readPointer = this.bufferImpl.length + whence; this.readPointer = this.bufferImpl.length + whence;
break; break;
@ -39,34 +38,56 @@ export class BufferStream {
return this.readPointer; return this.readPointer;
} }
tell() { return this.seek(0, SeekDir.CUR); } tell() {
return this.seek(0, SeekDir.CUR);
}
// common impl function for read*() // common impl function for read*()
private readImpl<T>(func: (this: DataView, offset: number, le?: boolean|undefined) => T, size: number, le?: boolean|undefined) { private readImpl<T>(func: (this: DataView, offset: number, le?: boolean | undefined) => T, size: number, le?: boolean | undefined) {
let res = func.call(this.dataView, this.readPointer, le); let res = func.call(this.dataView, this.readPointer, le);
this.readPointer += size; this.readPointer += size;
return res; return res;
} }
// Creates a view of a part of the buffer. // Creates a view of a part of the buffer.
// THIS DOES NOT DEEP COPY! // THIS DOES NOT DEEP COPY!
subBuffer(len: number) { subBuffer(len: number) {
let oldReadPointer = this.readPointer; let oldReadPointer = this.readPointer;
let buffer = this.bufferImpl.subarray(oldReadPointer, oldReadPointer + len); let buffer = this.bufferImpl.subarray(oldReadPointer, oldReadPointer + len);
this.readPointer += len; this.readPointer += len;
return new BufferStream(buffer, oldReadPointer); return new BufferStream(buffer, oldReadPointer);
} }
readS8() { return this.readImpl(DataView.prototype.getInt8, 1); } readS8() {
readU8() { return this.readImpl(DataView.prototype.getUint8, 1); } return this.readImpl(DataView.prototype.getInt8, 1);
readS16LE() { return this.readImpl(DataView.prototype.getInt16, 2, true); } }
readS16BE() { return this.readImpl(DataView.prototype.getInt16, 2, false); } readU8() {
readU16LE() { return this.readImpl(DataView.prototype.getUint16, 2, true); } return this.readImpl(DataView.prototype.getUint8, 1);
readU16BE() { return this.readImpl(DataView.prototype.getUint16, 2, false); } }
readS32LE() { return this.readImpl(DataView.prototype.getInt32, 4, true); } readS16LE() {
readS32BE() { return this.readImpl(DataView.prototype.getInt32, 4, false); } return this.readImpl(DataView.prototype.getInt16, 2, true);
readU32LE() { return this.readImpl(DataView.prototype.getUint32, 4, true); } }
readU32BE() { return this.readImpl(DataView.prototype.getUint32, 4, false); } readS16BE() {
return this.readImpl(DataView.prototype.getInt16, 2, false);
}
readU16LE() {
return this.readImpl(DataView.prototype.getUint16, 2, true);
}
readU16BE() {
return this.readImpl(DataView.prototype.getUint16, 2, false);
}
readS32LE() {
return this.readImpl(DataView.prototype.getInt32, 4, true);
}
readS32BE() {
return this.readImpl(DataView.prototype.getInt32, 4, false);
}
readU32LE() {
return this.readImpl(DataView.prototype.getUint32, 4, true);
}
readU32BE() {
return this.readImpl(DataView.prototype.getUint32, 4, false);
}
// Use this for temporary offset modification, e.g: when reading // Use this for temporary offset modification, e.g: when reading
// a structure *pointed to* inside another structure. // a structure *pointed to* inside another structure.
@ -77,27 +98,25 @@ export class BufferStream {
this.seek(last, SeekDir.BEG); this.seek(last, SeekDir.BEG);
} }
readBool() : boolean { readBool(): boolean {
let res = this.readU8(); let res = this.readU8();
return res != 0; return res != 0;
} }
readString<TChar extends number>(len: number, charReader: (this: BufferStream) => TChar): string { readString<TChar extends number>(len: number, charReader: (this: BufferStream) => TChar): string {
let str = ""; let str = '';
for(let i = 0; i < len; ++i) for (let i = 0; i < len; ++i) str += String.fromCharCode(charReader.call(this));
str += String.fromCharCode(charReader.call(this));
// dispose of a nul terminator. We don't support other bare Agent formats, // dispose of a nul terminator. We don't support other bare Agent formats,
// so we shouldn't need to add the "support" for that. // so we shouldn't need to add the "support" for that.
charReader.call(this); charReader.call(this);
return str; return str;
} }
readPascalString(lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE, charReader: (this: BufferStream) => number = BufferStream.prototype.readU16LE) { readPascalString(lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE, charReader: (this: BufferStream) => number = BufferStream.prototype.readU16LE) {
let len = lengthReader.call(this); let len = lengthReader.call(this);
if(len == 0) if (len == 0) return '';
return "";
return this.readString(len, charReader); return this.readString(len, charReader);
} }
@ -107,25 +126,22 @@ export class BufferStream {
return this.subBuffer(len); return this.subBuffer(len);
} }
readDataChunk(lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE) { readDataChunk(lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE) {
return this.readDataChunkBuffer(lengthReader).raw(); return this.readDataChunkBuffer(lengthReader).raw();
} }
// reads a counted list. The length reader is on the other end so you don't need to specify it // reads a counted list. The length reader is on the other end so you don't need to specify it
// (if it's u32) // (if it's u32)
readCountedList<TObject>(objReader: (stream: BufferStream) => TObject, lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE): TObject[] { readCountedList<TObject>(objReader: (stream: BufferStream) => TObject, lengthReader: (this: BufferStream) => number = BufferStream.prototype.readU32LE): TObject[] {
let len = lengthReader.call(this); let len = lengthReader.call(this);
let arr: TObject[] = []; let arr: TObject[] = [];
if(len == 0) if (len == 0) return arr;
return arr;
for(let i = 0; i < len; ++i) for (let i = 0; i < len; ++i) arr.push(objReader(this));
arr.push(objReader(this));
return arr; return arr;
} }
raw() { raw() {
return this.bufferImpl; return this.bufferImpl;
} }

View file

@ -35,7 +35,6 @@ export function agentCharacterParseACS(buffer: BufferStream): AcsData {
let imageInfoLocation = LOCATION.read(buffer); let imageInfoLocation = LOCATION.read(buffer);
let audioInfoLocation = LOCATION.read(buffer); let audioInfoLocation = LOCATION.read(buffer);
buffer.withOffset(characterInfoLocation.offset, () => { buffer.withOffset(characterInfoLocation.offset, () => {
acsData.characterInfo = AcsCharacterInfo.read(buffer); acsData.characterInfo = AcsCharacterInfo.read(buffer);
}); });
@ -59,9 +58,9 @@ export function agentCreateCharacter(data: AcsData): Agent {
return new Agent(data); return new Agent(data);
} }
export async function agentCreateCharacterFromUrl(url: string) : Promise<Agent> { export async function agentCreateCharacterFromUrl(url: string): Promise<Agent> {
// just return the cache object // just return the cache object
if(acsDataCache.has(url)) { if (acsDataCache.has(url)) {
return agentCreateCharacter(acsDataCache.get(url)!); return agentCreateCharacter(acsDataCache.get(url)!);
} else { } else {
let res = await fetch(url); let res = await fetch(url);

View file

@ -1,80 +1,84 @@
export class ContextMenuItem { export class ContextMenuItem {
private element: HTMLLIElement; private element: HTMLLIElement;
name: string; name: string;
cb: Function; cb: Function;
constructor(name: string, cb: Function) { constructor(name: string, cb: Function) {
this.name = name; this.name = name;
this.cb = cb; this.cb = cb;
this.element = document.createElement("li"); this.element = document.createElement('li');
this.element.classList.add("context-menu-item"); this.element.classList.add('context-menu-item');
this.element.innerText = name; this.element.innerText = name;
this.element.addEventListener('mousedown', () => this.cb()); this.element.addEventListener('mousedown', () => this.cb());
} }
getElement() { getElement() {
return this.element; return this.element;
} }
setName(name: string) { setName(name: string) {
this.name = name; this.name = name;
this.element.innerText = name; this.element.innerText = name;
} }
setCb(cb: Function) { setCb(cb: Function) {
this.cb = cb; this.cb = cb;
} }
} }
export class ContextMenu { export class ContextMenu {
private element: HTMLDivElement; private element: HTMLDivElement;
private list: HTMLUListElement; private list: HTMLUListElement;
private items: Array<ContextMenuItem> private items: Array<ContextMenuItem>;
constructor(parent: HTMLElement) { constructor(parent: HTMLElement) {
this.element = document.createElement("div"); this.element = document.createElement('div');
this.list = document.createElement("ul"); this.list = document.createElement('ul');
this.element.appendChild(this.list); this.element.appendChild(this.list);
this.items = []; this.items = [];
this.element.classList.add("context-menu"); this.element.classList.add('context-menu');
this.element.style.display = "none"; this.element.style.display = 'none';
this.element.style.position = "fixed"; this.element.style.position = 'fixed';
parent.appendChild(this.element); parent.appendChild(this.element);
} }
show(x: number, y: number) { show(x: number, y: number) {
this.element.style.left = x + "px"; this.element.style.left = x + 'px';
this.element.style.top = y + "px"; this.element.style.top = y + 'px';
document.addEventListener('mousedown', () => { document.addEventListener(
this.hide(); 'mousedown',
}, {once: true}); () => {
this.element.style.display = "block"; this.hide();
} },
{ once: true }
);
this.element.style.display = 'block';
}
hide() { hide() {
this.element.style.display = "none"; this.element.style.display = 'none';
} }
addItem(item: ContextMenuItem) { addItem(item: ContextMenuItem) {
this.items.push(item); this.items.push(item);
this.list.appendChild(item.getElement()); this.list.appendChild(item.getElement());
} }
removeItem(item: ContextMenuItem) { removeItem(item: ContextMenuItem) {
let i = this.items.indexOf(item); let i = this.items.indexOf(item);
if (i === -1) return; if (i === -1) return;
this.items.splice(i, 1); this.items.splice(i, 1);
item.getElement().remove(); item.getElement().remove();
} }
getItem(name: string) { getItem(name: string) {
return this.items.find(i => i.name === name); return this.items.find((i) => i.name === name);
} }
clearItems() { clearItems() {
this.items.splice(0, this.items.length); this.items.splice(0, this.items.length);
this.list.replaceChildren(); this.list.replaceChildren();
} }
} }

View file

@ -14,16 +14,14 @@ export async function compressInit() {
compressWasm = await WebAssembly.instantiateStreaming(fetch(url)); compressWasm = await WebAssembly.instantiateStreaming(fetch(url));
} }
function compressWasmGetExports() { function compressWasmGetExports() {
return (compressWasm.instance.exports as any) as CompressWasmExports; return compressWasm.instance.exports as any as CompressWasmExports;
} }
function compressWASMGetMemory() : WebAssembly.Memory { function compressWASMGetMemory(): WebAssembly.Memory {
return compressWasmGetExports().memory; return compressWasmGetExports().memory;
} }
// debugging // debugging
//(window as any).DEBUGcompressGetWASM = () => { //(window as any).DEBUGcompressGetWASM = () => {
// return compressWasm; // return compressWasm;
@ -35,10 +33,10 @@ export function compressDecompress(src: Uint8Array, dest: Uint8Array) {
// Grow the WASM heap if needed. Funnily enough, this code is never hit in most // Grow the WASM heap if needed. Funnily enough, this code is never hit in most
// ACSes, so IDK if it's even needed // ACSes, so IDK if it's even needed
let memory = compressWASMGetMemory(); let memory = compressWASMGetMemory();
if(memory.buffer.byteLength < src.length + dest.length) { if (memory.buffer.byteLength < src.length + dest.length) {
// A WebAssembly page is 64kb, so we need to grow at least that much // A WebAssembly page is 64kb, so we need to grow at least that much
let npages = Math.floor((src.length + dest.length) / 65535) + 1; let npages = Math.floor((src.length + dest.length) / 65535) + 1;
console.log("Need to grow WASM heap", npages, "pages", "(current byteLength is", memory.buffer.byteLength, ", we need", src.length + dest.length, ")"); console.log('Need to grow WASM heap', npages, 'pages', '(current byteLength is', memory.buffer.byteLength, ', we need', src.length + dest.length, ')');
memory.grow(npages); memory.grow(npages);
} }
@ -50,8 +48,7 @@ export function compressDecompress(src: Uint8Array, dest: Uint8Array) {
// Call the WASM compression routine // Call the WASM compression routine
let nrBytesDecompressed = compressWasmGetExports().agentDecompressWASM(0, src.length, src.length, dest.length); let nrBytesDecompressed = compressWasmGetExports().agentDecompressWASM(0, src.length, src.length, dest.length);
if(nrBytesDecompressed != dest.length) if (nrBytesDecompressed != dest.length) throw new Error(`decompression failed: ${nrBytesDecompressed} != ${dest.length}`);
throw new Error(`decompression failed: ${nrBytesDecompressed} != ${dest.length}`);
// Dest will be memory[src.length..dest.length] // Dest will be memory[src.length..dest.length]
dest.set(copyBuffer.slice(src.length, src.length + dest.length), 0); dest.set(copyBuffer.slice(src.length, src.length + dest.length), 0);

View file

@ -1,16 +1,15 @@
import { compressInit } from "./decompress.js"; import { compressInit } from './decompress.js';
import { wordballoonInit } from "./wordballoon.js"; import { wordballoonInit } from './wordballoon.js';
export * from "./types.js";
export * from "./character.js";
export * from "./decompress.js";
export * from "./sprite.js";
export * from "./wordballoon.js";
export * from "./contextmenu.js";
export * from './types.js';
export * from './character.js';
export * from './decompress.js';
export * from './sprite.js';
export * from './wordballoon.js';
export * from './contextmenu.js';
// Convinence function which initalizes all of msagent.js. // Convinence function which initalizes all of msagent.js.
export async function agentInit() { export async function agentInit() {
await compressInit(); await compressInit();
await wordballoonInit(); await wordballoonInit();
} }

View file

@ -108,7 +108,7 @@ export class RGBAColor {
//quad.g = (val & 0x00ff0000) >> 16; //quad.g = (val & 0x00ff0000) >> 16;
//quad.b = (val & 0x0000ff00) >> 8; //quad.b = (val & 0x0000ff00) >> 8;
quad.r = (val & 0x000000ff); quad.r = val & 0x000000ff;
quad.g = (val & 0x0000ff00) >> 8; quad.g = (val & 0x0000ff00) >> 8;
quad.b = (val & 0x00ff0000) >> 16; quad.b = (val & 0x00ff0000) >> 16;

View file

@ -29,17 +29,17 @@ export class AcsImage {
image.data = data; image.data = data;
} }
let temp = COMPRESSED_DATABLOCK.read(buffer); let temp = COMPRESSED_DATABLOCK.read(buffer);
let tempBuffer = new BufferStream(temp.data); let tempBuffer = new BufferStream(temp.data);
image.regionData = RGNDATA.read(tempBuffer); image.regionData = RGNDATA.read(tempBuffer);
return image; return image;
} }
} }
export class AcsImageEntry { export class AcsImageEntry {
image = new AcsImage(); image = new AcsImage();
static read(buffer: BufferStream) { static read(buffer: BufferStream) {
let image = new AcsImageEntry(); let image = new AcsImageEntry();
@ -48,9 +48,9 @@ export class AcsImageEntry {
let loc = LOCATION.read(buffer); let loc = LOCATION.read(buffer);
let checksum = buffer.readU32LE(); let checksum = buffer.readU32LE();
buffer.withOffset(loc.offset, () => { buffer.withOffset(loc.offset, () => {
image.image = AcsImage.read(buffer); image.image = AcsImage.read(buffer);
}); });
return image; return image;
} }

View file

@ -1,16 +1,16 @@
export type Rect = { export type Rect = {
x: number, x: number;
y: number, y: number;
w: number, w: number;
h: number h: number;
}; };
export type Size = { export type Size = {
w: number, w: number;
h: number h: number;
}; };
export type Point = { export type Point = {
x: number, x: number;
y: number y: number;
}; };

View file

@ -5,7 +5,6 @@ let corner_sprite: HTMLImageElement;
let straight_sprite: HTMLImageElement; let straight_sprite: HTMLImageElement;
let tip_sprite: HTMLImageElement; let tip_sprite: HTMLImageElement;
// Call *once* to initalize the wordballoon drawing system. // Call *once* to initalize the wordballoon drawing system.
// Do not call other wordballoon* functions WITHOUT doing so. // Do not call other wordballoon* functions WITHOUT doing so.
export async function wordballoonInit() { export async function wordballoonInit() {
@ -68,7 +67,7 @@ export function wordballoonDraw(ctx: CanvasRenderingContext2D, at: Point, size:
spriteDrawRotated(ctx, corner_sprite, 90, at.x + 12 * i, at.y); spriteDrawRotated(ctx, corner_sprite, 90, at.x + 12 * i, at.y);
// Draw both the left and right sides of the box. We can do this in one pass // Draw both the left and right sides of the box. We can do this in one pass
// so we do that for simplicity. // so we do that for simplicity.
let j = 1; let j = 1;
for (; j < size.h / 12; ++j) { for (; j < size.h / 12; ++j) {
spriteDrawRotated(ctx, straight_sprite, 270, at.x, at.y + 12 * j); spriteDrawRotated(ctx, straight_sprite, 270, at.x, at.y + 12 * j);
@ -89,12 +88,11 @@ export function wordballoonDraw(ctx: CanvasRenderingContext2D, at: Point, size:
// TODO: a tip point should be provided. We will pick the best corner to stick it on, // TODO: a tip point should be provided. We will pick the best corner to stick it on,
// and the best y coordinate on that corner to stick it on. // and the best y coordinate on that corner to stick it on.
// //
// For now, we always simply use the center of the bottom.. // For now, we always simply use the center of the bottom..
// Draw the tip. // Draw the tip.
if (hasTip) if (hasTip) spriteDraw(ctx, tip_sprite, at.x + size.w / 2, at.y + 12 * (j + 1) - 1);
spriteDraw(ctx, tip_sprite, at.x + size.w / 2, at.y + 12 * (j + 1) - 1);
ctx.restore(); ctx.restore();
@ -109,7 +107,7 @@ export function wordballoonDraw(ctx: CanvasRenderingContext2D, at: Point, size:
function wordWrapToStringList(text: string, maxLength: number) { function wordWrapToStringList(text: string, maxLength: number) {
// this was stolen off stackoverflow, it sucks but it (kind of) works // this was stolen off stackoverflow, it sucks but it (kind of) works
// it should probably be replaced at some point. // it should probably be replaced at some point.
var result = [], var result = [],
line: string[] = []; line: string[] = [];
var length = 0; var length = 0;
@ -129,7 +127,7 @@ function wordWrapToStringList(text: string, maxLength: number) {
} }
// This draws a wordballoon with text. This function respects the current context's font settings and does *not* modify them. // This draws a wordballoon with text. This function respects the current context's font settings and does *not* modify them.
export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, text: string, maxLen: number = 20, hasTip: boolean = true, color: string = "#000000"): Rect { export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, text: string, maxLen: number = 20, hasTip: boolean = true, color: string = '#000000'): Rect {
let lines = wordWrapToStringList(text, maxLen); let lines = wordWrapToStringList(text, maxLen);
// Create metrics for each line // Create metrics for each line
@ -158,10 +156,10 @@ export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, te
size.w = Math.floor(size.w + 12); size.w = Math.floor(size.w + 12);
size.h = Math.floor(size.h); size.h = Math.floor(size.h);
// Draw the word balloon and get the inner rect // Draw the word balloon and get the inner rect
let rectInner = wordballoonDraw(ctx, at, size, hasTip); let rectInner = wordballoonDraw(ctx, at, size, hasTip);
// Draw all the lines of text // Draw all the lines of text
let y = 0; let y = 0;
for (let i in lines) { for (let i in lines) {
let metric = metrics[i]; let metric = metrics[i];
@ -175,7 +173,7 @@ export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, te
return { return {
x: at.x, x: at.x,
y: at.y, y: at.y,
w: rectInner.w + (12*3) + 12, w: rectInner.w + 12 * 3 + 12,
h: rectInner.h + (13*3) + 18 h: rectInner.h + 13 * 3 + 18
} };
} }

View file

@ -6,11 +6,15 @@
"protocol", "protocol",
"msagent.js" "msagent.js"
], ],
"scripts": {
"format": "prettier --write **/*.{ts,html,scss}"
},
"packageManager": "yarn@4.2.2", "packageManager": "yarn@4.2.2",
"devDependencies": { "devDependencies": {
"@parcel/packager-ts": "2.12.0", "@parcel/packager-ts": "2.12.0",
"@parcel/transformer-sass": "2.12.0", "@parcel/transformer-sass": "2.12.0",
"@parcel/transformer-typescript-types": "2.12.0", "@parcel/transformer-typescript-types": "2.12.0",
"prettier": "^3.3.3",
"typescript": ">=3.0.0" "typescript": ">=3.0.0"
} }
} }

View file

@ -1,64 +1,64 @@
import { MSAgentProtocolMessage, MSAgentProtocolMessageType } from "./protocol"; import { MSAgentProtocolMessage, MSAgentProtocolMessageType } from './protocol';
export enum MSAgentAdminOperation { export enum MSAgentAdminOperation {
// Client-to-server // Client-to-server
Kick = "kick", Kick = 'kick',
Ban = "ban", Ban = 'ban',
// Bidirectional // Bidirectional
Login = "login", Login = 'login',
GetIP = "ip", GetIP = 'ip'
} }
export interface MSAgentAdminMessage extends MSAgentProtocolMessage { export interface MSAgentAdminMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Admin, op: MSAgentProtocolMessageType.Admin;
data: { data: {
action: MSAgentAdminOperation action: MSAgentAdminOperation;
} };
} }
// Client-to-server // Client-to-server
export interface MSAgentAdminLoginMessage extends MSAgentAdminMessage { export interface MSAgentAdminLoginMessage extends MSAgentAdminMessage {
data: { data: {
action: MSAgentAdminOperation.Login, action: MSAgentAdminOperation.Login;
password: string password: string;
} };
} }
export interface MSAgentAdminGetIPMessage extends MSAgentAdminMessage { export interface MSAgentAdminGetIPMessage extends MSAgentAdminMessage {
data: { data: {
action: MSAgentAdminOperation.GetIP, action: MSAgentAdminOperation.GetIP;
username: string username: string;
} };
} }
export interface MSAgentAdminKickMessage extends MSAgentAdminMessage { export interface MSAgentAdminKickMessage extends MSAgentAdminMessage {
data: { data: {
action: MSAgentAdminOperation.Kick, action: MSAgentAdminOperation.Kick;
username: string username: string;
} };
} }
export interface MSAgentAdminBanMessage extends MSAgentAdminMessage { export interface MSAgentAdminBanMessage extends MSAgentAdminMessage {
data: { data: {
action: MSAgentAdminOperation.Ban, action: MSAgentAdminOperation.Ban;
username: string username: string;
} };
} }
// Server-to-client // Server-to-client
export interface MSAgentAdminLoginResponse extends MSAgentAdminMessage { export interface MSAgentAdminLoginResponse extends MSAgentAdminMessage {
data: { data: {
action: MSAgentAdminOperation.Login, action: MSAgentAdminOperation.Login;
success: boolean success: boolean;
} };
} }
export interface MSAgentAdminGetIPResponse extends MSAgentAdminMessage { export interface MSAgentAdminGetIPResponse extends MSAgentAdminMessage {
data: { data: {
action: MSAgentAdminOperation.GetIP, action: MSAgentAdminOperation.GetIP;
username: string username: string;
ip: string ip: string;
} };
} }

View file

@ -1,91 +1,91 @@
export * from './admin.js'; export * from './admin.js';
export enum MSAgentProtocolMessageType { export enum MSAgentProtocolMessageType {
// Client-to-server // Client-to-server
KeepAlive = "nop", KeepAlive = 'nop',
Join = "join", Join = 'join',
Talk = "talk", Talk = 'talk',
Admin = "admin", Admin = 'admin',
// Server-to-client // Server-to-client
Init = "init", Init = 'init',
AddUser = "adduser", AddUser = 'adduser',
RemoveUser = "remuser", RemoveUser = 'remuser',
Chat = "chat", Chat = 'chat',
Promote = "promote", Promote = 'promote',
Error = "error" Error = 'error'
} }
export interface MSAgentProtocolMessage { export interface MSAgentProtocolMessage {
op: MSAgentProtocolMessageType op: MSAgentProtocolMessageType;
} }
// Client-to-server // Client-to-server
export interface MSAgentJoinMessage extends MSAgentProtocolMessage { export interface MSAgentJoinMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Join, op: MSAgentProtocolMessageType.Join;
data: { data: {
username: string; username: string;
agent: string; agent: string;
} };
} }
export interface MSAgentTalkMessage extends MSAgentProtocolMessage { export interface MSAgentTalkMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Talk, op: MSAgentProtocolMessageType.Talk;
data: { data: {
msg: string; msg: string;
} };
} }
// Server-to-client // Server-to-client
export interface MSAgentInitMessage extends MSAgentProtocolMessage { export interface MSAgentInitMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Init, op: MSAgentProtocolMessageType.Init;
data: { data: {
username: string username: string;
agent: string agent: string;
charlimit: number charlimit: number;
users: { users: {
username: string, username: string;
agent: string, agent: string;
admin: boolean admin: boolean;
}[] }[];
} };
} }
export interface MSAgentAddUserMessage extends MSAgentProtocolMessage { export interface MSAgentAddUserMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.AddUser, op: MSAgentProtocolMessageType.AddUser;
data: { data: {
username: string; username: string;
agent: string; agent: string;
} };
} }
export interface MSAgentRemoveUserMessage extends MSAgentProtocolMessage { export interface MSAgentRemoveUserMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.RemoveUser, op: MSAgentProtocolMessageType.RemoveUser;
data: { data: {
username: string; username: string;
} };
} }
export interface MSAgentChatMessage extends MSAgentProtocolMessage { export interface MSAgentChatMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Chat, op: MSAgentProtocolMessageType.Chat;
data: { data: {
username: string; username: string;
message: string; message: string;
audio? : string | undefined; audio?: string | undefined;
} };
} }
export interface MSAgentPromoteMessage extends MSAgentProtocolMessage { export interface MSAgentPromoteMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Promote, op: MSAgentProtocolMessageType.Promote;
data: { data: {
username: string; username: string;
} };
} }
export interface MSAgentErrorMessage extends MSAgentProtocolMessage { export interface MSAgentErrorMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Error, op: MSAgentProtocolMessageType.Error;
data: { data: {
error: string; error: string;
} };
} }

View file

@ -1,236 +1,246 @@
import EventEmitter from "events"; import EventEmitter from 'events';
import { WebSocket } from "ws"; import { WebSocket } from 'ws';
import { MSAgentAdminBanMessage, MSAgentAdminGetIPMessage, MSAgentAdminGetIPResponse, MSAgentAdminKickMessage, MSAgentAdminLoginMessage, MSAgentAdminLoginResponse, MSAgentAdminMessage, MSAgentAdminOperation, MSAgentErrorMessage, MSAgentJoinMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentTalkMessage } from '@msagent-chat/protocol'; import {
import { MSAgentChatRoom } from "./room.js"; MSAgentAdminBanMessage,
MSAgentAdminGetIPMessage,
MSAgentAdminGetIPResponse,
MSAgentAdminKickMessage,
MSAgentAdminLoginMessage,
MSAgentAdminLoginResponse,
MSAgentAdminMessage,
MSAgentAdminOperation,
MSAgentErrorMessage,
MSAgentJoinMessage,
MSAgentProtocolMessage,
MSAgentProtocolMessageType,
MSAgentTalkMessage
} from '@msagent-chat/protocol';
import { MSAgentChatRoom } from './room.js';
import * as htmlentities from 'html-entities'; import * as htmlentities from 'html-entities';
import RateLimiter from "./ratelimiter.js"; import RateLimiter from './ratelimiter.js';
import { createHash } from "crypto"; import { createHash } from 'crypto';
// Event types // Event types
export interface Client { export interface Client {
on(event: 'join', listener: () => void): this; on(event: 'join', listener: () => void): this;
on(event: 'close', listener: () => void): this; on(event: 'close', listener: () => void): this;
on(event: 'talk', listener: (msg: string) => void): this; on(event: 'talk', listener: (msg: string) => void): this;
on(event: string, listener: Function): this; on(event: string, listener: Function): this;
} }
export class Client extends EventEmitter { export class Client extends EventEmitter {
ip: string; ip: string;
username: string | null; username: string | null;
agent: string | null; agent: string | null;
admin: boolean; admin: boolean;
room: MSAgentChatRoom; room: MSAgentChatRoom;
socket: WebSocket; socket: WebSocket;
nopTimer: NodeJS.Timeout | undefined; nopTimer: NodeJS.Timeout | undefined;
nopLevel: number; nopLevel: number;
chatRateLimit: RateLimiter chatRateLimit: RateLimiter;
constructor(socket: WebSocket, room: MSAgentChatRoom, ip: string) { constructor(socket: WebSocket, room: MSAgentChatRoom, ip: string) {
super(); super();
this.socket = socket; this.socket = socket;
this.ip = ip; this.ip = ip;
this.room = room; this.room = room;
this.username = null; this.username = null;
this.agent = null; this.agent = null;
this.admin = false; this.admin = false;
this.resetNop(); this.resetNop();
this.nopLevel = 0; this.nopLevel = 0;
this.chatRateLimit = new RateLimiter(this.room.config.ratelimits.chat);
this.socket.on('message', (msg, isBinary) => { this.chatRateLimit = new RateLimiter(this.room.config.ratelimits.chat);
if (isBinary) {
this.socket.close();
return;
}
this.parseMessage(msg.toString("utf-8"));
});
this.socket.on('error', () => {});
this.socket.on('close', () => {
this.emit('close');
});
}
send(msg: MSAgentProtocolMessage) { this.socket.on('message', (msg, isBinary) => {
return new Promise<void>((res, rej) => { if (isBinary) {
if (this.socket.readyState !== WebSocket.OPEN) { this.socket.close();
res(); return;
return; }
} this.parseMessage(msg.toString('utf-8'));
this.socket.send(JSON.stringify(msg), err => { });
if (err) { this.socket.on('error', () => {});
rej(err); this.socket.on('close', () => {
return; this.emit('close');
} });
res(); }
});
});
}
private resetNop() { send(msg: MSAgentProtocolMessage) {
clearInterval(this.nopTimer); return new Promise<void>((res, rej) => {
this.nopLevel = 0; if (this.socket.readyState !== WebSocket.OPEN) {
this.nopTimer = setInterval(() => { res();
if (this.nopLevel++ >= 3) { return;
this.socket.close(); }
} else { this.socket.send(JSON.stringify(msg), (err) => {
this.send({ if (err) {
op: MSAgentProtocolMessageType.KeepAlive rej(err);
}); return;
} }
}, 10000) res();
} });
});
}
private async parseMessage(data: string) { private resetNop() {
let msg: MSAgentProtocolMessage; clearInterval(this.nopTimer);
try { this.nopLevel = 0;
msg = JSON.parse(data); this.nopTimer = setInterval(() => {
} catch { if (this.nopLevel++ >= 3) {
this.socket.close(); this.socket.close();
return; } else {
} this.send({
this.resetNop(); op: MSAgentProtocolMessageType.KeepAlive
switch (msg.op) { });
case MSAgentProtocolMessageType.Join: { }
let joinMsg = msg as MSAgentJoinMessage; }, 10000);
if (!joinMsg.data || !joinMsg.data.username || !joinMsg.data.username) { }
this.socket.close();
return; private async parseMessage(data: string) {
} let msg: MSAgentProtocolMessage;
let username = joinMsg.data.username.trim(); try {
if (!validateUsername(username)) { msg = JSON.parse(data);
let msg: MSAgentErrorMessage = { } catch {
op: MSAgentProtocolMessageType.Error, this.socket.close();
data: { return;
error: "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters." }
} this.resetNop();
}; switch (msg.op) {
await this.send(msg); case MSAgentProtocolMessageType.Join: {
this.socket.close(); let joinMsg = msg as MSAgentJoinMessage;
return; if (!joinMsg.data || !joinMsg.data.username || !joinMsg.data.username) {
} this.socket.close();
if (this.room.config.bannedWords.some(w => username.indexOf(w) !== -1)) { return;
this.socket.close(); }
return; let username = joinMsg.data.username.trim();
} if (!validateUsername(username)) {
if (this.room.clients.some(u => u.username === username)) { let msg: MSAgentErrorMessage = {
let i = 1; op: MSAgentProtocolMessageType.Error,
let uo = username; data: {
do { error: 'Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.'
username = uo + i++; }
} while (this.room.clients.some(u => u.username === username)) };
} await this.send(msg);
if (!this.room.agents.some(a => a.filename === joinMsg.data.agent)) { this.socket.close();
this.socket.close(); return;
return; }
} if (this.room.config.bannedWords.some((w) => username.indexOf(w) !== -1)) {
this.username = username; this.socket.close();
this.agent = joinMsg.data.agent; return;
this.emit('join'); }
break; if (this.room.clients.some((u) => u.username === username)) {
} let i = 1;
case MSAgentProtocolMessageType.Talk: { let uo = username;
let talkMsg = msg as MSAgentTalkMessage; do {
if (!talkMsg.data || !talkMsg.data.msg || !this.chatRateLimit.request()) { username = uo + i++;
return; } while (this.room.clients.some((u) => u.username === username));
} }
if (talkMsg.data.msg.length > this.room.config.charlimit) return; if (!this.room.agents.some((a) => a.filename === joinMsg.data.agent)) {
if (this.room.config.bannedWords.some(w => talkMsg.data.msg.indexOf(w) !== -1)) { this.socket.close();
return; return;
} }
this.emit('talk', talkMsg.data.msg); this.username = username;
break; this.agent = joinMsg.data.agent;
} this.emit('join');
case MSAgentProtocolMessageType.Admin: { break;
let adminMsg = msg as MSAgentAdminMessage; }
if (!adminMsg.data) return; case MSAgentProtocolMessageType.Talk: {
switch (adminMsg.data.action) { let talkMsg = msg as MSAgentTalkMessage;
case MSAgentAdminOperation.Login: { if (!talkMsg.data || !talkMsg.data.msg || !this.chatRateLimit.request()) {
let loginMsg = adminMsg as MSAgentAdminLoginMessage; return;
if (this.admin || !loginMsg.data.password) return; }
let sha256 = createHash("sha256"); if (talkMsg.data.msg.length > this.room.config.charlimit) return;
sha256.update(loginMsg.data.password); if (this.room.config.bannedWords.some((w) => talkMsg.data.msg.indexOf(w) !== -1)) {
let hash = sha256.digest("hex"); return;
sha256.destroy(); }
let success = false; this.emit('talk', talkMsg.data.msg);
if (hash === this.room.config.adminPasswordHash) { break;
this.admin = true; }
success = true; case MSAgentProtocolMessageType.Admin: {
this.emit('admin'); let adminMsg = msg as MSAgentAdminMessage;
} if (!adminMsg.data) return;
let res : MSAgentAdminLoginResponse = { switch (adminMsg.data.action) {
op: MSAgentProtocolMessageType.Admin, case MSAgentAdminOperation.Login: {
data: { let loginMsg = adminMsg as MSAgentAdminLoginMessage;
action: MSAgentAdminOperation.Login, if (this.admin || !loginMsg.data.password) return;
success let sha256 = createHash('sha256');
} sha256.update(loginMsg.data.password);
} let hash = sha256.digest('hex');
this.send(res); sha256.destroy();
break; let success = false;
} if (hash === this.room.config.adminPasswordHash) {
case MSAgentAdminOperation.GetIP: { this.admin = true;
let getIPMsg = adminMsg as MSAgentAdminGetIPMessage; success = true;
if (!this.admin || !getIPMsg.data || !getIPMsg.data.username) return; this.emit('admin');
let _user = this.room.clients.find(c => c.username === getIPMsg.data.username); }
if (!_user) return; let res: MSAgentAdminLoginResponse = {
let res: MSAgentAdminGetIPResponse = { op: MSAgentProtocolMessageType.Admin,
op: MSAgentProtocolMessageType.Admin, data: {
data: { action: MSAgentAdminOperation.Login,
action: MSAgentAdminOperation.GetIP, success
username: _user.username!, }
ip: _user.ip };
} this.send(res);
}; break;
this.send(res); }
break; case MSAgentAdminOperation.GetIP: {
} let getIPMsg = adminMsg as MSAgentAdminGetIPMessage;
case MSAgentAdminOperation.Kick: { if (!this.admin || !getIPMsg.data || !getIPMsg.data.username) return;
let kickMsg = adminMsg as MSAgentAdminKickMessage; let _user = this.room.clients.find((c) => c.username === getIPMsg.data.username);
if (!this.admin || !kickMsg.data || !kickMsg.data.username) return; if (!_user) return;
let _user = this.room.clients.find(c => c.username === kickMsg.data.username); let res: MSAgentAdminGetIPResponse = {
if (!_user) return; op: MSAgentProtocolMessageType.Admin,
let res: MSAgentErrorMessage = { data: {
op: MSAgentProtocolMessageType.Error, action: MSAgentAdminOperation.GetIP,
data: { username: _user.username!,
error: "You have been kicked." ip: _user.ip
} }
}; };
await _user.send(res); this.send(res);
_user.socket.close(); break;
break; }
} case MSAgentAdminOperation.Kick: {
case MSAgentAdminOperation.Ban: { let kickMsg = adminMsg as MSAgentAdminKickMessage;
let banMsg = adminMsg as MSAgentAdminBanMessage; if (!this.admin || !kickMsg.data || !kickMsg.data.username) return;
if (!this.admin || !banMsg.data || !banMsg.data.username) return; let _user = this.room.clients.find((c) => c.username === kickMsg.data.username);
let _user = this.room.clients.find(c => c.username === banMsg.data.username); if (!_user) return;
if (!_user) return; let res: MSAgentErrorMessage = {
let res: MSAgentErrorMessage = { op: MSAgentProtocolMessageType.Error,
op: MSAgentProtocolMessageType.Error, data: {
data: { error: 'You have been kicked.'
error: "You have been banned." }
} };
}; await _user.send(res);
await this.room.db.banUser(_user.ip, _user.username!); _user.socket.close();
await _user.send(res); break;
_user.socket.close(); }
break; case MSAgentAdminOperation.Ban: {
} let banMsg = adminMsg as MSAgentAdminBanMessage;
} if (!this.admin || !banMsg.data || !banMsg.data.username) return;
break; let _user = this.room.clients.find((c) => c.username === banMsg.data.username);
} if (!_user) return;
} let res: MSAgentErrorMessage = {
} op: MSAgentProtocolMessageType.Error,
data: {
error: 'You have been banned.'
}
};
await this.room.db.banUser(_user.ip, _user.username!);
await _user.send(res);
_user.socket.close();
break;
}
}
break;
}
}
}
} }
function validateUsername(username: string) { function validateUsername(username: string) {
return ( return username.length >= 3 && username.length <= 20 && /^[a-zA-Z0-9\ \-\_\.]+$/.test(username);
username.length >= 3 && }
username.length <= 20 &&
/^[a-zA-Z0-9\ \-\_\.]+$/.test(username)
);
}

View file

@ -1,60 +1,59 @@
export interface IConfig { export interface IConfig {
http: { http: {
host: string; host: string;
port: number; port: number;
proxied: boolean; proxied: boolean;
} };
mysql: MySQLConfig; mysql: MySQLConfig;
chat: ChatConfig; chat: ChatConfig;
motd: motdConfig; motd: motdConfig;
discord: DiscordConfig; discord: DiscordConfig;
tts: TTSConfig; tts: TTSConfig;
agents: AgentConfig[]; agents: AgentConfig[];
} }
export interface TTSConfig { export interface TTSConfig {
enabled: boolean; enabled: boolean;
server: string; server: string;
voice: string; voice: string;
tempDir: string; tempDir: string;
wavExpirySeconds: number; wavExpirySeconds: number;
} }
export interface ChatConfig { export interface ChatConfig {
charlimit: number; charlimit: number;
agentsDir: string; agentsDir: string;
maxConnectionsPerIP: number; maxConnectionsPerIP: number;
adminPasswordHash: string; adminPasswordHash: string;
bannedWords: string[]; bannedWords: string[];
ratelimits: { ratelimits: {
chat: RateLimitConfig; chat: RateLimitConfig;
} };
} }
export interface motdConfig { export interface motdConfig {
version: number; version: number;
html: string; html: string;
} }
export interface AgentConfig { export interface AgentConfig {
friendlyName: string; friendlyName: string;
filename: string; filename: string;
} }
export interface RateLimitConfig { export interface RateLimitConfig {
seconds: number; seconds: number;
limit: number; limit: number;
} }
export interface MySQLConfig { export interface MySQLConfig {
host: string; host: string;
username: string; username: string;
password: string; password: string;
database: string; database: string;
} }
export interface DiscordConfig { export interface DiscordConfig {
enabled: boolean; enabled: boolean;
webhookURL: string; webhookURL: string;
} }

View file

@ -1,38 +1,38 @@
import { MySQLConfig } from "./config.js"; import { MySQLConfig } from './config.js';
import * as mysql from 'mysql2/promise'; import * as mysql from 'mysql2/promise';
export class Database { export class Database {
private config: MySQLConfig; private config: MySQLConfig;
private db: mysql.Pool; private db: mysql.Pool;
constructor(config: MySQLConfig) {
this.config = config;
this.db = mysql.createPool({
host: this.config.host,
user: this.config.username,
password: this.config.password,
database: this.config.database,
connectionLimit: 10,
multipleStatements: false
});
}
async init() { constructor(config: MySQLConfig) {
let conn = await this.db.getConnection(); this.config = config;
await conn.execute("CREATE TABLE IF NOT EXISTS bans (ip VARCHAR(45) NOT NULL PRIMARY KEY, username TEXT NOT NULL, time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP());"); this.db = mysql.createPool({
conn.release(); host: this.config.host,
} user: this.config.username,
password: this.config.password,
database: this.config.database,
connectionLimit: 10,
multipleStatements: false
});
}
async banUser(ip: string, username: string) { async init() {
let conn = await this.db.getConnection(); let conn = await this.db.getConnection();
await conn.execute("INSERT INTO bans (ip, username) VALUES (?, ?)", [ip, username]); await conn.execute('CREATE TABLE IF NOT EXISTS bans (ip VARCHAR(45) NOT NULL PRIMARY KEY, username TEXT NOT NULL, time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP());');
conn.release(); conn.release();
} }
async isUserBanned(ip: string): Promise<boolean> { async banUser(ip: string, username: string) {
let conn = await this.db.getConnection(); let conn = await this.db.getConnection();
let res = await conn.query("SELECT COUNT(ip) AS cnt FROM bans WHERE ip = ?", [ip]) as mysql.RowDataPacket; await conn.execute('INSERT INTO bans (ip, username) VALUES (?, ?)', [ip, username]);
conn.release(); conn.release();
return res[0][0]["cnt"] !== 0; }
}
} async isUserBanned(ip: string): Promise<boolean> {
let conn = await this.db.getConnection();
let res = (await conn.query('SELECT COUNT(ip) AS cnt FROM bans WHERE ip = ?', [ip])) as mysql.RowDataPacket;
conn.release();
return res[0][0]['cnt'] !== 0;
}
}

View file

@ -1,18 +1,18 @@
import { WebhookClient } from "discord.js"; import { WebhookClient } from 'discord.js';
import { DiscordConfig } from "./config.js"; import { DiscordConfig } from './config.js';
export class DiscordLogger { export class DiscordLogger {
private webhook: WebhookClient; private webhook: WebhookClient;
constructor(config: DiscordConfig) {
this.webhook = new WebhookClient({url: config.webhookURL});
}
logMsg(username: string, msg: string) { constructor(config: DiscordConfig) {
this.webhook.send({ this.webhook = new WebhookClient({ url: config.webhookURL });
username, }
allowedMentions: {},
content: msg, logMsg(username: string, msg: string) {
}); this.webhook.send({
} username,
} allowedMentions: {},
content: msg
});
}
}

View file

@ -15,29 +15,27 @@ import { DiscordLogger } from './discord.js';
let config: IConfig; let config: IConfig;
let configPath: string; let configPath: string;
if (process.argv.length < 3) if (process.argv.length < 3) configPath = './config.toml';
configPath = "./config.toml"; else configPath = process.argv[2];
else
configPath = process.argv[2];
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
console.error(`${configPath} not found. Please copy config.example.toml and fill out fields.`); console.error(`${configPath} not found. Please copy config.example.toml and fill out fields.`);
process.exit(1); process.exit(1);
} }
try { try {
let configRaw = fs.readFileSync(configPath, "utf-8"); let configRaw = fs.readFileSync(configPath, 'utf-8');
config = toml.parse(configRaw); config = toml.parse(configRaw);
} catch (e) { } catch (e) {
console.error(`Failed to read or parse ${configPath}: ${(e as Error).message}`); console.error(`Failed to read or parse ${configPath}: ${(e as Error).message}`);
process.exit(1); process.exit(1);
} }
let db = new Database(config.mysql); let db = new Database(config.mysql);
await db.init(); await db.init();
const app = Fastify({ const app = Fastify({
logger: true, logger: true
}); });
app.register(FastifyWS); app.register(FastifyWS);
@ -45,100 +43,97 @@ app.register(FastifyWS);
let tts = null; let tts = null;
if (config.tts.enabled) { if (config.tts.enabled) {
tts = new TTSClient(config.tts); tts = new TTSClient(config.tts);
app.register(FastifyStatic, { app.register(FastifyStatic, {
root: config.tts.tempDir, root: config.tts.tempDir,
prefix: "/api/tts/", prefix: '/api/tts/',
decorateReply: false decorateReply: false
}); });
} }
if (!config.chat.agentsDir.endsWith("/")) config.chat.agentsDir += "/"; if (!config.chat.agentsDir.endsWith('/')) config.chat.agentsDir += '/';
if (!fs.existsSync(config.chat.agentsDir)) { if (!fs.existsSync(config.chat.agentsDir)) {
console.error(`Directory ${config.chat.agentsDir} does not exist.`); console.error(`Directory ${config.chat.agentsDir} does not exist.`);
process.exit(1); process.exit(1);
} }
for (let agent of config.agents) { for (let agent of config.agents) {
if (!fs.existsSync(path.join(config.chat.agentsDir, agent.filename))) { if (!fs.existsSync(path.join(config.chat.agentsDir, agent.filename))) {
console.error(`${agent.filename} does not exist.`); console.error(`${agent.filename} does not exist.`);
process.exit(1); process.exit(1);
} }
} }
app.register(FastifyStatic, { app.register(FastifyStatic, {
root: path.resolve(config.chat.agentsDir), root: path.resolve(config.chat.agentsDir),
prefix: "/api/agents/", prefix: '/api/agents/',
decorateReply: true, decorateReply: true
}); });
app.get("/api/agents", (req, res) => { app.get('/api/agents', (req, res) => {
return config.agents; return config.agents;
}); });
// MOTD // MOTD
app.get("/api/motd/version", (req, res) => { app.get('/api/motd/version', (req, res) => {
res.header("Content-Type", "text/plain"); res.header('Content-Type', 'text/plain');
return config.motd.version.toString(); return config.motd.version.toString();
}); });
app.get("/api/motd/html", (req, res) => { app.get('/api/motd/html', (req, res) => {
res.header("Content-Type", "text/html"); res.header('Content-Type', 'text/html');
return config.motd.html; return config.motd.html;
}); });
// Discord // Discord
let discord = null; let discord = null;
if (config.discord.enabled) { if (config.discord.enabled) {
discord = new DiscordLogger(config.discord); discord = new DiscordLogger(config.discord);
} }
let room = new MSAgentChatRoom(config.chat, config.agents, db, tts, discord); let room = new MSAgentChatRoom(config.chat, config.agents, db, tts, discord);
app.register(async app => { app.register(async (app) => {
app.get("/api/socket", {websocket: true}, async (socket, req) => { app.get('/api/socket', { websocket: true }, async (socket, req) => {
// TODO: Do this pre-upgrade and return the appropriate status codes // TODO: Do this pre-upgrade and return the appropriate status codes
let ip: string; let ip: string;
if (config.http.proxied) { if (config.http.proxied) {
if (req.headers["x-forwarded-for"] === undefined) { if (req.headers['x-forwarded-for'] === undefined) {
console.error(`Warning: X-Forwarded-For not set! This is likely a misconfiguration of your reverse proxy.`); console.error(`Warning: X-Forwarded-For not set! This is likely a misconfiguration of your reverse proxy.`);
socket.close(); socket.close();
return; return;
} }
let xff = req.headers["x-forwarded-for"]; let xff = req.headers['x-forwarded-for'];
if (xff instanceof Array) if (xff instanceof Array) ip = xff[0];
ip = xff[0]; else ip = xff;
else if (!isIP(ip)) {
ip = xff; console.error(`Warning: X-Forwarded-For malformed! This is likely a misconfiguration of your reverse proxy.`);
if (!isIP(ip)) { socket.close();
console.error(`Warning: X-Forwarded-For malformed! This is likely a misconfiguration of your reverse proxy.`); return;
socket.close(); }
return; } else {
} ip = req.ip;
} else { }
ip = req.ip; if (await db.isUserBanned(ip)) {
} let msg: MSAgentErrorMessage = {
if (await db.isUserBanned(ip)) { op: MSAgentProtocolMessageType.Error,
let msg: MSAgentErrorMessage = { data: {
op: MSAgentProtocolMessageType.Error, error: 'You have been banned.'
data: { }
error: "You have been banned." };
} socket.send(JSON.stringify(msg), () => {
} socket.close();
socket.send(JSON.stringify(msg), () => { });
socket.close(); return;
}); }
return; let o = room.clients.filter((c) => c.ip === ip);
} if (o.length >= config.chat.maxConnectionsPerIP) {
let o = room.clients.filter(c => c.ip === ip); o[0].socket.close();
if (o.length >= config.chat.maxConnectionsPerIP) { }
o[0].socket.close(); let client = new Client(socket, room, ip);
} room.addClient(client);
let client = new Client(socket, room, ip); });
room.addClient(client);
});
}); });
app.listen({ host: config.http.host, port: config.http.port });
app.listen({host: config.http.host, port: config.http.port});

View file

@ -32,4 +32,4 @@ export default class RateLimiter extends EventEmitter {
} }
return true; return true;
} }
} }

View file

@ -1,103 +1,113 @@
import { MSAgentAddUserMessage, MSAgentChatMessage, MSAgentInitMessage, MSAgentPromoteMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentRemoveUserMessage } from "@msagent-chat/protocol"; import {
import { Client } from "./client.js"; MSAgentAddUserMessage,
import { TTSClient } from "./tts.js"; MSAgentChatMessage,
import { AgentConfig, ChatConfig } from "./config.js"; MSAgentInitMessage,
MSAgentPromoteMessage,
MSAgentProtocolMessage,
MSAgentProtocolMessageType,
MSAgentRemoveUserMessage
} from '@msagent-chat/protocol';
import { Client } from './client.js';
import { TTSClient } from './tts.js';
import { AgentConfig, ChatConfig } from './config.js';
import * as htmlentities from 'html-entities'; import * as htmlentities from 'html-entities';
import { Database } from "./database.js"; import { Database } from './database.js';
import { DiscordLogger } from "./discord.js"; import { DiscordLogger } from './discord.js';
export class MSAgentChatRoom { export class MSAgentChatRoom {
agents: AgentConfig[]; agents: AgentConfig[];
clients: Client[]; clients: Client[];
tts: TTSClient | null; tts: TTSClient | null;
msgId : number = 0; msgId: number = 0;
config: ChatConfig; config: ChatConfig;
db: Database; db: Database;
discord: DiscordLogger | null; discord: DiscordLogger | null;
constructor(config: ChatConfig, agents: AgentConfig[], db: Database, tts: TTSClient | null, discord: DiscordLogger | null) { constructor(config: ChatConfig, agents: AgentConfig[], db: Database, tts: TTSClient | null, discord: DiscordLogger | null) {
this.agents = agents; this.agents = agents;
this.clients = []; this.clients = [];
this.config = config; this.config = config;
this.tts = tts; this.tts = tts;
this.db = db; this.db = db;
this.discord = discord; this.discord = discord;
} }
addClient(client: Client) { addClient(client: Client) {
this.clients.push(client); this.clients.push(client);
client.on('close', () => { client.on('close', () => {
this.clients.splice(this.clients.indexOf(client), 1); this.clients.splice(this.clients.indexOf(client), 1);
if (client.username === null) return; if (client.username === null) return;
let msg: MSAgentRemoveUserMessage = { let msg: MSAgentRemoveUserMessage = {
op: MSAgentProtocolMessageType.RemoveUser, op: MSAgentProtocolMessageType.RemoveUser,
data: { data: {
username: client.username username: client.username
} }
}; };
for (const _client of this.getActiveClients()) { for (const _client of this.getActiveClients()) {
_client.send(msg); _client.send(msg);
} }
}); });
client.on('join', () => { client.on('join', () => {
let initmsg : MSAgentInitMessage = { let initmsg: MSAgentInitMessage = {
op: MSAgentProtocolMessageType.Init, op: MSAgentProtocolMessageType.Init,
data: { data: {
username: client.username!, username: client.username!,
agent: client.agent!, agent: client.agent!,
charlimit: this.config.charlimit, charlimit: this.config.charlimit,
users: this.clients.filter(c => c.username !== null).map(c => { users: this.clients
return { .filter((c) => c.username !== null)
username: c.username!, .map((c) => {
agent: c.agent!, return {
admin: c.admin username: c.username!,
} agent: c.agent!,
}) admin: c.admin
} };
}; })
client.send(initmsg); }
let msg: MSAgentAddUserMessage = { };
op: MSAgentProtocolMessageType.AddUser, client.send(initmsg);
data: { let msg: MSAgentAddUserMessage = {
username: client.username!, op: MSAgentProtocolMessageType.AddUser,
agent: client.agent! data: {
} username: client.username!,
} agent: client.agent!
for (const _client of this.getActiveClients().filter(c => c !== client)) { }
_client.send(msg); };
} for (const _client of this.getActiveClients().filter((c) => c !== client)) {
}); _client.send(msg);
client.on('talk', async message => { }
let msg: MSAgentChatMessage = { });
op: MSAgentProtocolMessageType.Chat, client.on('talk', async (message) => {
data: { let msg: MSAgentChatMessage = {
username: client.username!, op: MSAgentProtocolMessageType.Chat,
message: message data: {
} username: client.username!,
}; message: message
if (this.tts !== null) { }
let filename = await this.tts.synthesizeToFile(message, (++this.msgId).toString(10)); };
msg.data.audio = "/api/tts/" + filename; if (this.tts !== null) {
} let filename = await this.tts.synthesizeToFile(message, (++this.msgId).toString(10));
for (const _client of this.getActiveClients()) { msg.data.audio = '/api/tts/' + filename;
_client.send(msg); }
} for (const _client of this.getActiveClients()) {
this.discord?.logMsg(client.username!, message); _client.send(msg);
}); }
client.on('admin', () => { this.discord?.logMsg(client.username!, message);
let msg: MSAgentPromoteMessage = { });
op: MSAgentProtocolMessageType.Promote, client.on('admin', () => {
data: { let msg: MSAgentPromoteMessage = {
username: client.username! op: MSAgentProtocolMessageType.Promote,
} data: {
}; username: client.username!
for (const _client of this.getActiveClients()) { }
_client.send(msg); };
} for (const _client of this.getActiveClients()) {
}); _client.send(msg);
} }
});
}
private getActiveClients() { private getActiveClients() {
return this.clients.filter(c => c.username !== null); return this.clients.filter((c) => c.username !== null);
} }
} }

View file

@ -1,70 +1,73 @@
import path from "path"; import path from 'path';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { TTSConfig } from "./config.js"; import { TTSConfig } from './config.js';
import { Readable } from "node:stream"; import { Readable } from 'node:stream';
import { ReadableStream } from 'node:stream/web'; import { ReadableStream } from 'node:stream/web';
import { finished } from "node:stream/promises"; import { finished } from 'node:stream/promises';
export class TTSClient { export class TTSClient {
private config: TTSConfig; private config: TTSConfig;
private deleteOps: Map<string, NodeJS.Timeout> private deleteOps: Map<string, NodeJS.Timeout>;
constructor(config: TTSConfig) { constructor(config: TTSConfig) {
this.config = config; this.config = config;
if (!this.config.tempDir.endsWith('/')) this.config.tempDir += '/'; if (!this.config.tempDir.endsWith('/')) this.config.tempDir += '/';
this.deleteOps = new Map(); this.deleteOps = new Map();
} }
async ensureDirectoryExists() { async ensureDirectoryExists() {
let stat; let stat;
try { try {
stat = await fs.stat(this.config.tempDir); stat = await fs.stat(this.config.tempDir);
} catch (e) { } catch (e) {
let error = e as NodeJS.ErrnoException; let error = e as NodeJS.ErrnoException;
switch (error.code) { switch (error.code) {
case "ENOTDIR": { case 'ENOTDIR': {
console.warn("File exists at TTS temp directory path. Unlinking..."); console.warn('File exists at TTS temp directory path. Unlinking...');
await fs.unlink(this.config.tempDir.substring(0, this.config.tempDir.length - 1)); await fs.unlink(this.config.tempDir.substring(0, this.config.tempDir.length - 1));
// intentional fall-through // intentional fall-through
} }
case "ENOENT": { case 'ENOENT': {
await fs.mkdir(this.config.tempDir, {recursive: true}); await fs.mkdir(this.config.tempDir, { recursive: true });
break; break;
} }
default: { default: {
console.error(`Cannot access TTS Temp dir: ${error.message}`); console.error(`Cannot access TTS Temp dir: ${error.message}`);
process.exit(1); process.exit(1);
break; break;
} }
} }
} }
} }
async synthesizeToFile(text: string, id: string) : Promise<string> { async synthesizeToFile(text: string, id: string): Promise<string> {
this.ensureDirectoryExists(); this.ensureDirectoryExists();
let wavFilename = id + ".wav" let wavFilename = id + '.wav';
let wavPath = path.join(this.config.tempDir, wavFilename); let wavPath = path.join(this.config.tempDir, wavFilename);
try { try {
await fs.unlink(wavPath); await fs.unlink(wavPath);
} catch {} } catch {}
let file = await fs.open(wavPath, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY); let file = await fs.open(wavPath, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY);
let stream = file.createWriteStream(); let stream = file.createWriteStream();
let res = await fetch(this.config.server + "/api/synthesize", { let res = await fetch(this.config.server + '/api/synthesize', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json" 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
text, text,
voice: this.config.voice voice: this.config.voice
}) })
}); });
await finished(Readable.fromWeb(res.body as ReadableStream<any>).pipe(stream)); await finished(Readable.fromWeb(res.body as ReadableStream<any>).pipe(stream));
await file.close(); await file.close();
this.deleteOps.set(wavPath, setTimeout(async () => { this.deleteOps.set(
await fs.unlink(wavPath); wavPath,
this.deleteOps.delete(wavPath); setTimeout(async () => {
}, this.config.wavExpirySeconds * 1000)); await fs.unlink(wavPath);
return wavFilename; this.deleteOps.delete(wavPath);
} }, this.config.wavExpirySeconds * 1000)
} );
return wavFilename;
}
}

View file

@ -1,4 +1,4 @@
export const Config = { export const Config = {
// The server address for the webapp to connect to. The below default is the same address the webapp is hosted at. // The server address for the webapp to connect to. The below default is the same address the webapp is hosted at.
serverAddress: `${window.location.protocol}//${window.location.host}` serverAddress: `${window.location.protocol}//${window.location.host}`
} };

View file

@ -1,130 +1,130 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" prefix="og: https://ogp.me/ns#"> <html lang="en" prefix="og: https://ogp.me/ns#">
<head> <head>
<title>MSAgent Chat</title> <title>MSAgent Chat</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content="MSAgent Chat" /> <meta property="og:title" content="MSAgent Chat" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Computernewb's Internet Pub Hub" /> <meta property="og:site_name" content="Computernewb's Internet Pub Hub" />
<link rel="stylesheet" href="https://unpkg.com/98.css" /> <link rel="stylesheet" href="https://unpkg.com/98.css" />
<link type="text/css" rel="stylesheet" href="../css/style.scss" /> <link type="text/css" rel="stylesheet" href="../css/style.scss" />
</head> </head>
<body> <body>
<div id="logonView"> <div id="logonView">
<div class="window" id="logonWindow"> <div class="window" id="logonWindow">
<div class="title-bar"> <div class="title-bar">
<div class="title-bar-text">Log on to Agent Chat</div> <div class="title-bar-text">Log on to Agent Chat</div>
</div> </div>
<div class="window-body"> <div class="window-body">
<div id="logonWindowLogo"> <div id="logonWindowLogo">
<img src="../../assets/agentchat.png?width=361&height=82"/> <img src="../../assets/agentchat.png?width=361&height=82" />
</div> </div>
<form id="logonForm"> <form id="logonForm">
<div id="logonUsernameContainer"> <div id="logonUsernameContainer">
<label for="logonUsername">User name:</label> <label for="logonUsername">User name:</label>
<input type="text" id="logonUsername" required/> <input type="text" id="logonUsername" required />
</div> </div>
<div id="logonRoomContainer"> <div id="logonRoomContainer">
<label for="logonRoom">Room name:</label> <label for="logonRoom">Room name:</label>
<input type="text" id="logonRoom" placeholder="Coming Soon" disabled/> <input type="text" id="logonRoom" placeholder="Coming Soon" disabled />
</div> </div>
<div id="logonButtonsContainer"> <div id="logonButtonsContainer">
<select id="agentSelect"> <select id="agentSelect">
<option value="" disabled selected hidden>Agent</option> <option value="" disabled selected hidden>Agent</option>
</select> </select>
<button type="submit" id="logonButton">Log on</button> <button type="submit" id="logonButton">Log on</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="window" id="motdWindow"> <div class="window" id="motdWindow">
<div class="title-bar"> <div class="title-bar">
<div class="title-bar-text"> <div class="title-bar-text">Welcome to Agent Chat</div>
Welcome to Agent Chat <div class="title-bar-controls">
</div> <button aria-label="Close"></button>
<div class="title-bar-controls"> </div>
<button aria-label="Close"></button> </div>
</div> <div class="window-body">
</div> <div id="motdContainer"></div>
<div class="window-body"> </div>
<div id="motdContainer"></div> </div>
</div>
</div>
<ul id="bottomLinks"> <ul id="bottomLinks">
<li><a href="#" id="rulesLink">Rules</a></li><li><a target="blank" href="https://discord.gg/a4kqb4mGyX">Discord</a></li><li><a target="blank" href="https://git.computernewb.com/computernewb/msagent-chat">Source Code</a></li> <li><a href="#" id="rulesLink">Rules</a></li>
</ul> <li><a target="blank" href="https://discord.gg/a4kqb4mGyX">Discord</a></li>
</div> <li><a target="blank" href="https://git.computernewb.com/computernewb/msagent-chat">Source Code</a></li>
<div id="chatView"> </ul>
<div class="window" id="roomSettingsWindow"> </div>
<div class="title-bar"> <div id="chatView">
<div class="title-bar-text">Room Settings</div> <div class="window" id="roomSettingsWindow">
<div class="title-bar-controls"> <div class="title-bar">
<button aria-label="Minimize"></button> <div class="title-bar-text">Room Settings</div>
<button aria-label="Close"></button> <div class="title-bar-controls">
</div> <button aria-label="Minimize"></button>
</div> <button aria-label="Close"></button>
<div class="window-body" id="roomSettingsBody"> </div>
<menu role="tablist"> </div>
<li role="tab" aria-selected="true"><a>Wallpaper</a></li> <div class="window-body" id="roomSettingsBody">
</menu> <menu role="tablist">
<div class="window" role="tabpanel" id="roomSettingsTabContent"> <li role="tab" aria-selected="true"><a>Wallpaper</a></li>
<div class="window-body" id="roomSettingsWallpaperTab"> </menu>
<div class="wallpaperMonitor"> <div class="window" role="tabpanel" id="roomSettingsTabContent">
<div> <div class="window-body" id="roomSettingsWallpaperTab">
<div class="wallpaperMonitorFg" id="roomWallpaperPreview"></div> <div class="wallpaperMonitor">
<img src="../../assets/monitor.png"/> <div>
</div> <div class="wallpaperMonitorFg" id="roomWallpaperPreview"></div>
</div> <img src="../../assets/monitor.png" />
<div> </div>
<span>Select a picture or enter a URL to use as a wallpaper:</span> </div>
<div id="roomSettingsWallpaperSelect"> <div>
<div class="mainCol"> <span>Select a picture or enter a URL to use as a wallpaper:</span>
<ul class="tree-view"> <div id="roomSettingsWallpaperSelect">
<!-- Should be actual links later but this is just a mockup for now :P --> <div class="mainCol">
<li>None</li> <ul class="tree-view">
<li>Blue Lace 16</li> <!-- Should be actual links later but this is just a mockup for now :P -->
<li>Boiling Point</li> <li>None</li>
<li>Chateau</li> <li>Blue Lace 16</li>
<li>Coffee Bean</li> <li>Boiling Point</li>
<li>Fall Memories</li> <li>Chateau</li>
<li>FeatherTexture</li> <li>Coffee Bean</li>
</ul> <li>Fall Memories</li>
<input type="text" placeholder="Wallpaper URL" id="roomSettingsWallpaperURL"/> <li>FeatherTexture</li>
</div> </ul>
<div> <input type="text" placeholder="Wallpaper URL" id="roomSettingsWallpaperURL" />
<div class="field-row-stacked"> </div>
<label for="roomSettingsWallpaperDisplay">Picture Display:</label> <div>
<select id="roomSettingsWallpaperDisplay"> <div class="field-row-stacked">
<option selected>Center</option> <label for="roomSettingsWallpaperDisplay">Picture Display:</label>
<option>Tile</option> <select id="roomSettingsWallpaperDisplay">
<option>Stretch</option> <option selected>Center</option>
</select> <option>Tile</option>
</div> <option>Stretch</option>
<div class="field-row"> </select>
<!-- *Should* be a HTML color select, but 98.css doesn't support those grr --> </div>
<button>Color</button> <div class="field-row">
</div> <!-- *Should* be a HTML color select, but 98.css doesn't support those grr -->
</div> <button>Color</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="field-row fieldRowRight"> </div>
<button>OK</button> </div>
<button>Cancel</button> <div class="field-row fieldRowRight">
<button>Apply</button> <button>OK</button>
</div> <button>Cancel</button>
</div> <button>Apply</button>
</div> </div>
</div>
<div id="chatBar"> </div>
<input type="text" id="chatInput" placeholder="Send a message"/>
<button id="chatSendBtn">Send</button> <div id="chatBar">
</div> <input type="text" id="chatInput" placeholder="Send a message" />
</div> <button id="chatSendBtn">Send</button>
<script type="module" src="../ts/main.ts"></script> </div>
</body> </div>
</html> <script type="module" src="../ts/main.ts"></script>
</body>
</html>

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" prefix="og: https://ogp.me/ns#"> <html lang="en" prefix="og: https://ogp.me/ns#">
<head> <head>
<title>MSAgent Chat - testbed</title> <title>MSAgent Chat - testbed</title>
@ -19,13 +19,12 @@
</div> </div>
<div class="field-row-stacked" style="width: 400px"> <div class="field-row-stacked" style="width: 400px">
<form id="acsUrlForm"> <form id="acsUrlForm">
<label for="acsUrl">Load ACS from URL</label><br> <label for="acsUrl">Load ACS from URL</label><br />
<input type="url" id="acsUrl" required/><br> <input type="url" id="acsUrl" required /><br />
<input type="submit" value="Load"/> <input type="submit" value="Load" />
</form> </form>
</div> </div>
<!-- This is where all Agents go --> <!-- This is where all Agents go -->
<div id="agent-mount"> <div id="agent-mount"></div>
</div>
</body> </body>
</html> </html>

View file

@ -1,110 +1,111 @@
export interface MSWindowConfig { export interface MSWindowConfig {
minWidth: number, minWidth: number;
minHeight: number, minHeight: number;
maxWidth?: number | undefined, maxWidth?: number | undefined;
maxHeight?: number | undefined, maxHeight?: number | undefined;
startPosition: MSWindowStartPosition // TODO: Should be a union with the enum and a "Point" (containing X and Y) startPosition: MSWindowStartPosition; // TODO: Should be a union with the enum and a "Point" (containing X and Y)
} }
export enum MSWindowStartPosition { export enum MSWindowStartPosition {
TopLeft, TopLeft,
Center Center
} }
export class MSWindow { export class MSWindow {
wnd: HTMLDivElement; wnd: HTMLDivElement;
closeBtn: HTMLButtonElement | undefined; closeBtn: HTMLButtonElement | undefined;
config: MSWindowConfig; config: MSWindowConfig;
titlebar: HTMLDivElement; titlebar: HTMLDivElement;
body: HTMLDivElement; body: HTMLDivElement;
shown: boolean; shown: boolean;
dragging: boolean; dragging: boolean;
x: number; x: number;
y: number; y: number;
constructor(wnd: HTMLDivElement, config: MSWindowConfig) { constructor(wnd: HTMLDivElement, config: MSWindowConfig) {
this.wnd = wnd; this.wnd = wnd;
this.shown = false; this.shown = false;
this.config = config; this.config = config;
this.wnd.style.minWidth = config.minWidth + "px"; this.wnd.style.minWidth = config.minWidth + 'px';
this.wnd.style.minHeight = config.minHeight + "px"; this.wnd.style.minHeight = config.minHeight + 'px';
if (config.maxWidth) if (config.maxWidth) this.wnd.style.maxWidth = config.maxWidth + 'px';
this.wnd.style.maxWidth = config.maxWidth + "px"; if (config.maxHeight) this.wnd.style.maxHeight = config.maxHeight + 'px';
if (config.maxHeight)
this.wnd.style.maxHeight = config.maxHeight + "px";
this.wnd.classList.add("d-none"); this.wnd.classList.add('d-none');
let titlebar = this.wnd.querySelector("div.title-bar"); let titlebar = this.wnd.querySelector('div.title-bar');
let body = this.wnd.querySelector("div.window-body"); let body = this.wnd.querySelector('div.window-body');
if (!titlebar || !body) if (!titlebar || !body) throw new Error('MSWindow is missing titlebar or body element.');
throw new Error("MSWindow is missing titlebar or body element.");
this.titlebar = titlebar as HTMLDivElement; this.titlebar = titlebar as HTMLDivElement;
this.body = body as HTMLDivElement; this.body = body as HTMLDivElement;
let closeBtn = this.titlebar.querySelector("div.title-bar-controls > button[aria-label='Close']") as HTMLButtonElement; let closeBtn = this.titlebar.querySelector("div.title-bar-controls > button[aria-label='Close']") as HTMLButtonElement;
if (closeBtn) { if (closeBtn) {
this.closeBtn = closeBtn; this.closeBtn = closeBtn;
closeBtn.addEventListener('click', () => { closeBtn.addEventListener('click', () => {
this.hide(); this.hide();
}); });
} }
// Register window move handlers // Register window move handlers
this.dragging = false; this.dragging = false;
switch (this.config.startPosition) { switch (this.config.startPosition) {
case MSWindowStartPosition.TopLeft: { case MSWindowStartPosition.TopLeft: {
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
break; break;
} }
case MSWindowStartPosition.Center: { case MSWindowStartPosition.Center: {
this.x = (document.documentElement.clientWidth / 2) - (this.config.minWidth / 2); this.x = document.documentElement.clientWidth / 2 - this.config.minWidth / 2;
this.y = (document.documentElement.clientHeight / 2) - (this.config.minHeight / 2); this.y = document.documentElement.clientHeight / 2 - this.config.minHeight / 2;
break; break;
} }
default: { default: {
throw new Error("Invalid start position"); throw new Error('Invalid start position');
} }
} }
this.setLoc(); this.setLoc();
this.titlebar.addEventListener('mousedown', () => { this.titlebar.addEventListener('mousedown', () => {
this.dragging = true; this.dragging = true;
document.addEventListener('mouseup', () => { document.addEventListener(
this.dragging = false; 'mouseup',
}, {once: true}); () => {
}); this.dragging = false;
},
{ once: true }
);
});
document.addEventListener('mousemove', e => { document.addEventListener('mousemove', (e) => {
if (!this.dragging) return; if (!this.dragging) return;
this.x += e.movementX; this.x += e.movementX;
this.y += e.movementY; this.y += e.movementY;
this.setLoc(); this.setLoc();
}); });
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
this.setLoc(); this.setLoc();
}); });
} }
show() { show() {
this.wnd.classList.remove("d-none"); this.wnd.classList.remove('d-none');
this.shown = true; this.shown = true;
} }
hide() { hide() {
this.wnd.classList.add("d-none"); this.wnd.classList.add('d-none');
this.shown = false; this.shown = false;
} }
private setLoc() { private setLoc() {
if (this.x < 0) this.x = 0; if (this.x < 0) this.x = 0;
if (this.y < 0) this.y = 0; if (this.y < 0) this.y = 0;
if (this.x > document.documentElement.clientWidth - this.config.minWidth) this.x = document.documentElement.clientWidth - this.config.minWidth; if (this.x > document.documentElement.clientWidth - this.config.minWidth) this.x = document.documentElement.clientWidth - this.config.minWidth;
if (this.y > document.documentElement.clientHeight - this.config.minHeight) this.y = document.documentElement.clientHeight - this.config.minHeight; if (this.y > document.documentElement.clientHeight - this.config.minHeight) this.y = document.documentElement.clientHeight - this.config.minHeight;
this.wnd.style.top = this.y + "px"; this.wnd.style.top = this.y + 'px';
this.wnd.style.left = this.x + "px"; this.wnd.style.left = this.x + 'px';
} }
} }

View file

@ -62,14 +62,17 @@ export class MSAgentClient {
this.users = []; this.users = [];
this.admin = false; this.admin = false;
document.addEventListener('keydown', this.loginCb = (e: KeyboardEvent) => { document.addEventListener(
if (e.key === "l" && e.ctrlKey) { 'keydown',
e.preventDefault(); (this.loginCb = (e: KeyboardEvent) => {
let password = window.prompt("Papers, please"); if (e.key === 'l' && e.ctrlKey) {
if (!password) return; e.preventDefault();
this.login(password); let password = window.prompt('Papers, please');
} if (!password) return;
}); this.login(password);
}
})
);
} }
on<E extends keyof MSAgentClientEvents>(event: E, callback: MSAgentClientEvents[E]): Unsubscribe { on<E extends keyof MSAgentClientEvents>(event: E, callback: MSAgentClientEvents[E]): Unsubscribe {
@ -82,13 +85,13 @@ export class MSAgentClient {
} }
async getMotd(): Promise<MOTD> { async getMotd(): Promise<MOTD> {
let res = await fetch(this.url + "/api/motd/version"); let res = await fetch(this.url + '/api/motd/version');
let vs = await res.text(); let vs = await res.text();
let version = parseInt(vs); let version = parseInt(vs);
if (isNaN(version)) { if (isNaN(version)) {
throw new Error("Version was NaN"); throw new Error('Version was NaN');
} }
res = await fetch(this.url + "/api/motd/html"); res = await fetch(this.url + '/api/motd/html');
let html = await res.text(); let html = await res.text();
return { return {
version, version,
@ -178,12 +181,12 @@ export class MSAgentClient {
ctx.clearItems(); ctx.clearItems();
// Mute // Mute
let _user = user; let _user = user;
let mute = new ContextMenuItem("Mute", () => { let mute = new ContextMenuItem('Mute', () => {
if (_user.muted) { if (_user.muted) {
mute.setName("Mute"); mute.setName('Mute');
_user.muted = false; _user.muted = false;
} else { } else {
mute.setName("Unmute"); mute.setName('Unmute');
_user.muted = true; _user.muted = true;
_user.agent.stopSpeaking(); _user.agent.stopSpeaking();
this.playingAudio.get(_user.username)?.pause(); this.playingAudio.get(_user.username)?.pause();
@ -193,7 +196,7 @@ export class MSAgentClient {
// Admin // Admin
if (this.admin) { if (this.admin) {
// Get IP // Get IP
let getip = new ContextMenuItem("Get IP", () => { let getip = new ContextMenuItem('Get IP', () => {
let msg: MSAgentAdminGetIPMessage = { let msg: MSAgentAdminGetIPMessage = {
op: MSAgentProtocolMessageType.Admin, op: MSAgentProtocolMessageType.Admin,
data: { data: {
@ -205,7 +208,7 @@ export class MSAgentClient {
}); });
ctx.addItem(getip); ctx.addItem(getip);
// Kick // Kick
let kick = new ContextMenuItem("Kick", () => { let kick = new ContextMenuItem('Kick', () => {
let msg: MSAgentAdminKickMessage = { let msg: MSAgentAdminKickMessage = {
op: MSAgentProtocolMessageType.Admin, op: MSAgentProtocolMessageType.Admin,
data: { data: {
@ -217,7 +220,7 @@ export class MSAgentClient {
}); });
ctx.addItem(kick); ctx.addItem(kick);
// Ban // Ban
let ban = new ContextMenuItem("Ban", () => { let ban = new ContextMenuItem('Ban', () => {
let msg: MSAgentAdminBanMessage = { let msg: MSAgentAdminBanMessage = {
op: MSAgentProtocolMessageType.Admin, op: MSAgentProtocolMessageType.Admin,
data: { data: {
@ -264,7 +267,7 @@ export class MSAgentClient {
this.charlimit = initMsg.data.charlimit; this.charlimit = initMsg.data.charlimit;
for (let _user of initMsg.data.users) { for (let _user of initMsg.data.users) {
let agent = await agentCreateCharacterFromUrl(this.url + '/api/agents/' + _user.agent); let agent = await agentCreateCharacterFromUrl(this.url + '/api/agents/' + _user.agent);
agent.setUsername(_user.username, _user.admin ? "#FF0000" : "#000000"); agent.setUsername(_user.username, _user.admin ? '#FF0000' : '#000000');
agent.addToDom(this.agentContainer); agent.addToDom(this.agentContainer);
agent.show(); agent.show();
let user = new User(_user.username, agent); let user = new User(_user.username, agent);
@ -277,7 +280,7 @@ export class MSAgentClient {
case MSAgentProtocolMessageType.AddUser: { case MSAgentProtocolMessageType.AddUser: {
let addUserMsg = msg as MSAgentAddUserMessage; let addUserMsg = msg as MSAgentAddUserMessage;
let agent = await agentCreateCharacterFromUrl(this.url + '/api/agents/' + addUserMsg.data.agent); let agent = await agentCreateCharacterFromUrl(this.url + '/api/agents/' + addUserMsg.data.agent);
agent.setUsername(addUserMsg.data.username, "#000000"); agent.setUsername(addUserMsg.data.username, '#000000');
agent.addToDom(this.agentContainer); agent.addToDom(this.agentContainer);
agent.show(); agent.show();
let user = new User(addUserMsg.data.username, agent); let user = new User(addUserMsg.data.username, agent);
@ -292,7 +295,7 @@ export class MSAgentClient {
if (!user) return; if (!user) return;
user.agent.hide(true); user.agent.hide(true);
if (this.playingAudio.has(user!.username)) { if (this.playingAudio.has(user!.username)) {
this.playingAudio.get(user!.username)?.pause(); this.playingAudio.get(user!.username)?.pause();
this.playingAudio.delete(user!.username); this.playingAudio.delete(user!.username);
} }
this.users.splice(this.users.indexOf(user), 1); this.users.splice(this.users.indexOf(user), 1);
@ -317,10 +320,10 @@ export class MSAgentClient {
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
// give a bit of time before the wordballoon disappears // give a bit of time before the wordballoon disappears
setTimeout(() => { setTimeout(() => {
if (this.playingAudio.get(user!.username) === audio) { if (this.playingAudio.get(user!.username) === audio) {
user!.agent.stopSpeaking(); user!.agent.stopSpeaking();
this.playingAudio.delete(user!.username); this.playingAudio.delete(user!.username);
} }
}, 1000); }, 1000);
}); });
@ -338,7 +341,7 @@ export class MSAgentClient {
this.admin = true; this.admin = true;
for (const user of this.users) this.setContextMenu(user); for (const user of this.users) this.setContextMenu(user);
} else { } else {
alert("Incorrect password!"); alert('Incorrect password!');
} }
break; break;
} }
@ -352,10 +355,10 @@ export class MSAgentClient {
} }
case MSAgentProtocolMessageType.Promote: { case MSAgentProtocolMessageType.Promote: {
let promoteMsg = msg as MSAgentPromoteMessage; let promoteMsg = msg as MSAgentPromoteMessage;
let user = this.users.find(u => u.username === promoteMsg.data.username); let user = this.users.find((u) => u.username === promoteMsg.data.username);
if (!user) return; if (!user) return;
user.admin = true; user.admin = true;
user.agent.setUsername(user.username, "#ff0000"); user.agent.setUsername(user.username, '#ff0000');
break; break;
} }
case MSAgentProtocolMessageType.Error: { case MSAgentProtocolMessageType.Error: {

View file

@ -1,122 +1,121 @@
import { MSWindow, MSWindowStartPosition } from "./MSWindow.js"; import { MSWindow, MSWindowStartPosition } from './MSWindow.js';
import { agentInit } from "@msagent-chat/msagent.js"; import { agentInit } from '@msagent-chat/msagent.js';
import { MSAgentClient } from "./client.js"; import { MSAgentClient } from './client.js';
import { Config } from "../../config.js"; import { Config } from '../../config.js';
const elements = { const elements = {
motdWindow: document.getElementById("motdWindow") as HTMLDivElement, motdWindow: document.getElementById('motdWindow') as HTMLDivElement,
motdContainer: document.getElementById("motdContainer") as HTMLDivElement, motdContainer: document.getElementById('motdContainer') as HTMLDivElement,
rulesLink: document.getElementById("rulesLink") as HTMLAnchorElement, rulesLink: document.getElementById('rulesLink') as HTMLAnchorElement,
logonView: document.getElementById("logonView") as HTMLDivElement, logonView: document.getElementById('logonView') as HTMLDivElement,
logonWindow: document.getElementById("logonWindow") as HTMLDivElement, logonWindow: document.getElementById('logonWindow') as HTMLDivElement,
logonForm: document.getElementById("logonForm") as HTMLFormElement, logonForm: document.getElementById('logonForm') as HTMLFormElement,
logonUsername: document.getElementById("logonUsername") as HTMLInputElement, logonUsername: document.getElementById('logonUsername') as HTMLInputElement,
logonButton: document.getElementById("logonButton") as HTMLButtonElement, logonButton: document.getElementById('logonButton') as HTMLButtonElement,
agentSelect: document.getElementById("agentSelect") as HTMLSelectElement, agentSelect: document.getElementById('agentSelect') as HTMLSelectElement,
chatView: document.getElementById("chatView") as HTMLDivElement, chatView: document.getElementById('chatView') as HTMLDivElement,
chatInput: document.getElementById("chatInput") as HTMLInputElement, chatInput: document.getElementById('chatInput') as HTMLInputElement,
chatSendBtn: document.getElementById("chatSendBtn") as HTMLButtonElement, chatSendBtn: document.getElementById('chatSendBtn') as HTMLButtonElement,
roomSettingsWindow: document.getElementById("roomSettingsWindow") as HTMLDivElement roomSettingsWindow: document.getElementById('roomSettingsWindow') as HTMLDivElement
} };
let Room : MSAgentClient; let Room: MSAgentClient;
function roomInit() { function roomInit() {
Room = new MSAgentClient(Config.serverAddress, elements.chatView); Room = new MSAgentClient(Config.serverAddress, elements.chatView);
Room.on('close', () => { Room.on('close', () => {
for (let user of Room.getUsers()) { for (let user of Room.getUsers()) {
user.agent.remove(); user.agent.remove();
} }
roomInit(); roomInit();
loggingIn = false; loggingIn = false;
elements.logonButton.disabled = false; elements.logonButton.disabled = false;
logonWindow.show(); logonWindow.show();
elements.logonView.style.display = "block"; elements.logonView.style.display = 'block';
elements.chatView.style.display = "none"; elements.chatView.style.display = 'none';
}); });
} }
let motdWindow = new MSWindow(elements.motdWindow, { let motdWindow = new MSWindow(elements.motdWindow, {
minWidth: 600, minWidth: 600,
minHeight: 300, minHeight: 300,
maxWidth: 600, maxWidth: 600,
startPosition: MSWindowStartPosition.Center startPosition: MSWindowStartPosition.Center
}); });
let logonWindow = new MSWindow(elements.logonWindow, { let logonWindow = new MSWindow(elements.logonWindow, {
minWidth: 500, minWidth: 500,
minHeight: 275, minHeight: 275,
startPosition: MSWindowStartPosition.Center startPosition: MSWindowStartPosition.Center
}); });
let roomSettingsWindow = new MSWindow(elements.roomSettingsWindow, { let roomSettingsWindow = new MSWindow(elements.roomSettingsWindow, {
minWidth: 398, minWidth: 398,
minHeight: 442, minHeight: 442,
startPosition: MSWindowStartPosition.Center startPosition: MSWindowStartPosition.Center
}); });
logonWindow.show(); logonWindow.show();
// roomSettingsWindow.show(); // roomSettingsWindow.show();
let loggingIn = false; let loggingIn = false;
elements.logonForm.addEventListener('submit', e => { elements.logonForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
connectToRoom(); connectToRoom();
}); });
elements.chatInput.addEventListener('keypress', e => { elements.chatInput.addEventListener('keypress', (e) => {
// enter // enter
if (e.key === "Enter") talk(); if (e.key === 'Enter') talk();
}); });
elements.chatSendBtn.addEventListener('click', () => { elements.chatSendBtn.addEventListener('click', () => {
talk(); talk();
}); });
async function connectToRoom() { async function connectToRoom() {
if (!elements.agentSelect.value) { if (!elements.agentSelect.value) {
alert("Please select an agent."); alert('Please select an agent.');
return; return;
} }
if (loggingIn) return; if (loggingIn) return;
loggingIn = true; loggingIn = true;
elements.logonButton.disabled = true; elements.logonButton.disabled = true;
await Room.connect(); await Room.connect();
await Room.join(elements.logonUsername.value, elements.agentSelect.value); await Room.join(elements.logonUsername.value, elements.agentSelect.value);
elements.chatInput.maxLength = Room.getCharlimit(); elements.chatInput.maxLength = Room.getCharlimit();
logonWindow.hide(); logonWindow.hide();
elements.logonView.style.display = "none"; elements.logonView.style.display = 'none';
elements.chatView.style.display = "block"; elements.chatView.style.display = 'block';
}; }
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await agentInit(); await agentInit();
for (const agent of await Room.getAgents()) { for (const agent of await Room.getAgents()) {
let option = document.createElement("option"); let option = document.createElement('option');
option.innerText = agent.friendlyName; option.innerText = agent.friendlyName;
option.value = agent.filename; option.value = agent.filename;
elements.agentSelect.appendChild(option); elements.agentSelect.appendChild(option);
} }
let motd = await Room.getMotd(); let motd = await Room.getMotd();
elements.motdContainer.innerHTML = motd.html; elements.motdContainer.innerHTML = motd.html;
let ver = localStorage.getItem("msagent-chat-motd-version"); let ver = localStorage.getItem('msagent-chat-motd-version');
if (!ver || parseInt(ver) !== motd.version) { if (!ver || parseInt(ver) !== motd.version) {
motdWindow.show(); motdWindow.show();
localStorage.setItem("msagent-chat-motd-version", motd.version.toString()); localStorage.setItem('msagent-chat-motd-version', motd.version.toString());
} }
elements.rulesLink.addEventListener('click', () => { elements.rulesLink.addEventListener('click', () => {
motdWindow.show(); motdWindow.show();
}) });
}); });
function talk() { function talk() {
if (Room === null) return; if (Room === null) return;
Room.talk(elements.chatInput.value); Room.talk(elements.chatInput.value);
elements.chatInput.value = ""; elements.chatInput.value = '';
} }
roomInit(); roomInit();

View file

@ -1,40 +1,40 @@
// Testbed code // Testbed code
// This will go away when it isn't needed // This will go away when it isn't needed
import * as msagent from "@msagent-chat/msagent.js"; import * as msagent from '@msagent-chat/msagent.js';
let w = window as any; let w = window as any;
w.agents = []; w.agents = [];
let input = document.getElementById("testbed-input") as HTMLInputElement; let input = document.getElementById('testbed-input') as HTMLInputElement;
let mount = document.getElementById("agent-mount") as HTMLDivElement; let mount = document.getElementById('agent-mount') as HTMLDivElement;
input.addEventListener("change", async () => { input.addEventListener('change', async () => {
let buffer = await input.files![0].arrayBuffer(); let buffer = await input.files![0].arrayBuffer();
console.log("Creating agent"); console.log('Creating agent');
let agent = msagent.agentCreateCharacter(new Uint8Array(buffer)); let agent = msagent.agentCreateCharacter(new Uint8Array(buffer));
w.agents.push(agent); w.agents.push(agent);
agent.addToDom(mount); agent.addToDom(mount);
agent.show(); agent.show();
console.log("Agent created"); console.log('Agent created');
}) });
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener('DOMContentLoaded', async () => {
await msagent.agentInit(); await msagent.agentInit();
console.log("msagent initalized!"); console.log('msagent initalized!');
}) });
let form = document.getElementById("acsUrlForm") as HTMLFormElement; let form = document.getElementById('acsUrlForm') as HTMLFormElement;
form.addEventListener('submit', e => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
let url = (document.getElementById("acsUrl") as HTMLInputElement).value; let url = (document.getElementById('acsUrl') as HTMLInputElement).value;
msagent.agentCreateCharacterFromUrl(url).then(agent => { msagent.agentCreateCharacterFromUrl(url).then((agent) => {
w.agents.push(agent); w.agents.push(agent);
agent.addToDom(document.body); agent.addToDom(document.body);
agent.show(); agent.show();
console.log(`Loaded agent from ${url}`); console.log(`Loaded agent from ${url}`);
}); });
}); });

View file

@ -1,15 +1,15 @@
import { Agent } from "@msagent-chat/msagent.js"; import { Agent } from '@msagent-chat/msagent.js';
export class User { export class User {
username: string; username: string;
agent: Agent; agent: Agent;
muted: boolean; muted: boolean;
admin: boolean; admin: boolean;
constructor(username: string, agent: Agent) { constructor(username: string, agent: Agent) {
this.username = username; this.username = username;
this.agent = agent; this.agent = agent;
this.muted = false; this.muted = false;
this.admin = false; this.admin = false;
} }
} }

View file

@ -3259,6 +3259,7 @@ __metadata:
"@parcel/packager-ts": "npm:2.12.0" "@parcel/packager-ts": "npm:2.12.0"
"@parcel/transformer-sass": "npm:2.12.0" "@parcel/transformer-sass": "npm:2.12.0"
"@parcel/transformer-typescript-types": "npm:2.12.0" "@parcel/transformer-typescript-types": "npm:2.12.0"
prettier: "npm:^3.3.3"
typescript: "npm:>=3.0.0" typescript: "npm:>=3.0.0"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -3694,6 +3695,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prettier@npm:^3.3.3":
version: 3.3.3
resolution: "prettier@npm:3.3.3"
bin:
prettier: bin/prettier.cjs
checksum: 10c0/b85828b08e7505716324e4245549b9205c0cacb25342a030ba8885aba2039a115dbcf75a0b7ca3b37bc9d101ee61fab8113fc69ca3359f2a226f1ecc07ad2e26
languageName: node
linkType: hard
"proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": "proc-log@npm:^4.1.0, proc-log@npm:^4.2.0":
version: 4.2.0 version: 4.2.0
resolution: "proc-log@npm:4.2.0" resolution: "proc-log@npm:4.2.0"