Add admin features (get ip, kick, ban), context menu, ban database, clean disconnect and reconnect, and errors.

This commit is contained in:
Elijah R 2024-07-12 02:00:20 -04:00
parent 78629b9cb7
commit 3525fdcff4
19 changed files with 626 additions and 25 deletions

View file

@ -1,5 +1,6 @@
import { BufferStream, SeekDir } from './buffer.js'; import { BufferStream, SeekDir } from './buffer.js';
import { AcsData } from './character.js'; import { AcsData } from './character.js';
import { ContextMenu, ContextMenuItem } from './contextmenu.js';
import { AcsAnimation, AcsAnimationFrameInfo } from './structs/animation.js'; import { AcsAnimation, AcsAnimationFrameInfo } from './structs/animation.js';
import { AcsImageEntry } from './structs/image.js'; import { AcsImageEntry } from './structs/image.js';
import { Point, Size } from './types.js'; import { Point, Size } from './types.js';
@ -69,7 +70,7 @@ class AgentWordBalloonState {
balloonCanvas: HTMLCanvasElement; balloonCanvas: HTMLCanvasElement;
balloonCanvasCtx: CanvasRenderingContext2D; balloonCanvasCtx: CanvasRenderingContext2D;
constructor(char: Agent, text: string, hasTip: boolean, position: AgentWordBalloonPosition) { constructor(char: Agent, text: string, hasTip: boolean, position: AgentWordBalloonPosition, textColor: string) {
this.char = char; this.char = char;
this.text = text; this.text = text;
this.hasTip = hasTip; this.hasTip = hasTip;
@ -87,13 +88,13 @@ class AgentWordBalloonState {
// hack fix for above // hack fix for above
this.balloonCanvas.style.pointerEvents = 'none'; this.balloonCanvas.style.pointerEvents = 'none';
let rect = wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip); let rect = wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip, textColor);
// Second pass, actually set the element to the right width and stuffs // Second pass, actually set the element to the right width and stuffs
this.balloonCanvas.width = rect.w; this.balloonCanvas.width = rect.w;
this.balloonCanvas.height = rect.h; this.balloonCanvas.height = rect.h;
wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip); wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip, textColor);
this.char.getElement().appendChild(this.balloonCanvas); this.char.getElement().appendChild(this.balloonCanvas);
@ -142,6 +143,8 @@ export class Agent {
private wordballoonState: AgentWordBalloonState | null = null; private wordballoonState: AgentWordBalloonState | null = null;
private usernameBalloonState: AgentWordBalloonState | null = null; private usernameBalloonState: AgentWordBalloonState | null = null;
private contextMenu: ContextMenu;
constructor(data: AcsData) { constructor(data: AcsData) {
this.data = data; this.data = data;
this.charDiv = document.createElement('div'); this.charDiv = document.createElement('div');
@ -155,6 +158,8 @@ export class Agent {
this.cnv.height = data.characterInfo.charHeight; this.cnv.height = data.characterInfo.charHeight;
this.cnv.style.display = 'none'; this.cnv.style.display = 'none';
this.contextMenu = new ContextMenu(this.charDiv);
this.charDiv.appendChild(this.cnv); this.charDiv.appendChild(this.cnv);
this.dragging = false; this.dragging = false;
@ -173,7 +178,7 @@ export class Agent {
}); });
this.cnv.addEventListener('contextmenu', (e) => { this.cnv.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
// TODO: Custom context menu support this.contextMenu.show(e.clientX, e.clientY);
}); });
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (!this.dragging) return; if (!this.dragging) return;
@ -201,6 +206,10 @@ export class Agent {
return this.charDiv; return this.charDiv;
} }
getContextMenu() {
return this.contextMenu;
}
getAt() { getAt() {
let point: Point = { let point: Point = {
x: this.x, x: this.x,
@ -284,13 +293,13 @@ export class Agent {
if (index !== -1) this.playAnimation(index, finishCallback); if (index !== -1) this.playAnimation(index, finishCallback);
} }
setUsername(username: string) { setUsername(username: string, color: string) {
if (this.usernameBalloonState !== null) { if (this.usernameBalloonState !== null) {
this.usernameBalloonState.finish(); this.usernameBalloonState.finish();
this.usernameBalloonState = null; this.usernameBalloonState = null;
} }
this.usernameBalloonState = new AgentWordBalloonState(this, username, false, AgentWordBalloonPosition.BelowCentered); this.usernameBalloonState = new AgentWordBalloonState(this, username, false, AgentWordBalloonPosition.BelowCentered, color);
this.usernameBalloonState.show(); this.usernameBalloonState.show();
} }
@ -299,7 +308,7 @@ export class Agent {
this.stopSpeaking(); this.stopSpeaking();
} }
this.wordballoonState = new AgentWordBalloonState(this, text, true, AgentWordBalloonPosition.AboveCentered); this.wordballoonState = new AgentWordBalloonState(this, text, true, AgentWordBalloonPosition.AboveCentered, "#000000");
this.wordballoonState.positionUpdated(); this.wordballoonState.positionUpdated();
this.wordballoonState.show(); this.wordballoonState.show();
} }

View file

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

View file

@ -6,6 +6,7 @@ export * from "./character.js";
export * from "./decompress.js"; export * from "./decompress.js";
export * from "./sprite.js"; export * from "./sprite.js";
export * from "./wordballoon.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.

View file

@ -129,7 +129,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): 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
@ -167,6 +167,7 @@ export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, te
let metric = metrics[i]; let metric = metrics[i];
let height = metric.actualBoundingBoxAscent + metric.actualBoundingBoxDescent; let height = metric.actualBoundingBoxAscent + metric.actualBoundingBoxDescent;
ctx.fillStyle = color;
ctx.fillText(lines[i], rectInner.x - 12, rectInner.y + y); ctx.fillText(lines[i], rectInner.x - 12, rectInner.y + y);
y += height * 1.25; y += height * 1.25;
} }

64
protocol/src/admin.ts Normal file
View file

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

View file

@ -1,12 +1,17 @@
export * from './admin.js';
export enum MSAgentProtocolMessageType { export enum MSAgentProtocolMessageType {
// Client-to-server // Client-to-server
Join = "join", Join = "join",
Talk = "talk", Talk = "talk",
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",
Error = "error"
} }
export interface MSAgentProtocolMessage { export interface MSAgentProtocolMessage {
@ -68,3 +73,17 @@ export interface MSAgentChatMessage extends MSAgentProtocolMessage {
audio? : string | undefined; audio? : string | undefined;
} }
} }
export interface MSAgentPromoteMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Promote,
data: {
username: string;
}
}
export interface MSAgentErrorMessage extends MSAgentProtocolMessage {
op: MSAgentProtocolMessageType.Error,
data: {
error: string;
}
}

View file

@ -3,10 +3,17 @@ host = "127.0.0.1"
port = 3000 port = 3000
proxied = true proxied = true
[mysql]
host = "127.0.0.1"
username = "agentchat"
password = "hunter2"
database = "agentchat"
[chat] [chat]
charlimit = 100 charlimit = 100
agentsDir = "agents/" agentsDir = "agents/"
maxConnectionsPerIP = 2 maxConnectionsPerIP = 2
adminPasswordHash = "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7"
[chat.ratelimits] [chat.ratelimits]
chat = {seconds = 10, limit = 8} chat = {seconds = 10, limit = 8}

View file

@ -15,6 +15,7 @@
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"html-entities": "^2.5.2", "html-entities": "^2.5.2",
"mysql2": "^3.10.2",
"toml": "^3.0.0", "toml": "^3.0.0",
"ws": "^8.17.1" "ws": "^8.17.1"
}, },

View file

@ -1,9 +1,10 @@
import EventEmitter from "events"; import EventEmitter from "events";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { MSAgentJoinMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentTalkMessage } from '@msagent-chat/protocol'; import { MSAgentAdminBanMessage, MSAgentAdminGetIPMessage, MSAgentAdminGetIPResponse, MSAgentAdminKickMessage, MSAgentAdminLoginMessage, MSAgentAdminLoginResponse, MSAgentAdminMessage, MSAgentAdminOperation, MSAgentErrorMessage, MSAgentJoinMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentTalkMessage } from '@msagent-chat/protocol';
import { MSAgentChatRoom } from "./room.js"; 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";
// Event types // Event types
@ -19,6 +20,7 @@ export class Client extends EventEmitter {
ip: string; ip: string;
username: string | null; username: string | null;
agent: string | null; agent: string | null;
admin: boolean;
room: MSAgentChatRoom; room: MSAgentChatRoom;
socket: WebSocket; socket: WebSocket;
@ -32,6 +34,7 @@ export class Client extends EventEmitter {
this.room = room; this.room = room;
this.username = null; this.username = null;
this.agent = null; this.agent = null;
this.admin = false;
this.chatRateLimit = new RateLimiter(this.room.config.ratelimits.chat); this.chatRateLimit = new RateLimiter(this.room.config.ratelimits.chat);
@ -64,7 +67,7 @@ export class Client extends EventEmitter {
}); });
} }
private parseMessage(data: string) { private async parseMessage(data: string) {
let msg: MSAgentProtocolMessage; let msg: MSAgentProtocolMessage;
try { try {
msg = JSON.parse(data); msg = JSON.parse(data);
@ -105,6 +108,83 @@ export class Client extends EventEmitter {
this.emit('talk', talkMsg.data.msg); this.emit('talk', talkMsg.data.msg);
break; break;
} }
case MSAgentProtocolMessageType.Admin: {
let adminMsg = msg as MSAgentAdminMessage;
if (!adminMsg.data) return;
switch (adminMsg.data.action) {
case MSAgentAdminOperation.Login: {
let loginMsg = adminMsg as MSAgentAdminLoginMessage;
if (this.admin || !loginMsg.data.password) return;
let sha256 = createHash("sha256");
sha256.update(loginMsg.data.password);
let hash = sha256.digest("hex");
sha256.destroy();
let success = false;
if (hash === this.room.config.adminPasswordHash) {
this.admin = true;
success = true;
this.emit('admin');
}
let res : MSAgentAdminLoginResponse = {
op: MSAgentProtocolMessageType.Admin,
data: {
action: MSAgentAdminOperation.Login,
success
}
}
this.send(res);
break;
}
case MSAgentAdminOperation.GetIP: {
let getIPMsg = adminMsg as MSAgentAdminGetIPMessage;
if (!this.admin || !getIPMsg.data || !getIPMsg.data.username) return;
let _user = this.room.clients.find(c => c.username === getIPMsg.data.username);
if (!_user) return;
let res: MSAgentAdminGetIPResponse = {
op: MSAgentProtocolMessageType.Admin,
data: {
action: MSAgentAdminOperation.GetIP,
username: _user.username!,
ip: _user.ip
}
};
this.send(res);
break;
}
case MSAgentAdminOperation.Kick: {
let kickMsg = adminMsg as MSAgentAdminKickMessage;
if (!this.admin || !kickMsg.data || !kickMsg.data.username) return;
let _user = this.room.clients.find(c => c.username === kickMsg.data.username);
if (!_user) return;
let res: MSAgentErrorMessage = {
op: MSAgentProtocolMessageType.Error,
data: {
error: "You have been kicked."
}
};
await _user.send(res);
_user.socket.close();
break;
}
case MSAgentAdminOperation.Ban: {
let banMsg = adminMsg as MSAgentAdminBanMessage;
if (!this.admin || !banMsg.data || !banMsg.data.username) return;
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;
}
} }
} }
} }

View file

@ -4,6 +4,7 @@ export interface IConfig {
port: number; port: number;
proxied: boolean; proxied: boolean;
} }
mysql: MySQLConfig;
chat: ChatConfig; chat: ChatConfig;
tts: TTSConfig; tts: TTSConfig;
agents: AgentConfig[]; agents: AgentConfig[];
@ -21,6 +22,7 @@ export interface ChatConfig {
charlimit: number; charlimit: number;
agentsDir: string; agentsDir: string;
maxConnectionsPerIP: number; maxConnectionsPerIP: number;
adminPasswordHash: string;
ratelimits: { ratelimits: {
chat: RateLimitConfig; chat: RateLimitConfig;
} }
@ -32,8 +34,14 @@ export interface AgentConfig {
} }
export interface RateLimitConfig { export interface RateLimitConfig {
seconds: number; seconds: number;
limit: number; limit: number;
} }
export interface MySQLConfig {
host: string;
username: string;
password: string;
database: string;
}

38
server/src/database.ts Normal file
View file

@ -0,0 +1,38 @@
import { MySQLConfig } from "./config.js";
import * as mysql from 'mysql2/promise';
export class Database {
private config: MySQLConfig;
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() {
let conn = await this.db.getConnection();
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();
}
async banUser(ip: string, username: string) {
let conn = await this.db.getConnection();
await conn.execute("INSERT INTO bans (ip, username) VALUES (?, ?)", [ip, username]);
conn.release();
}
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

@ -10,6 +10,8 @@ import { TTSClient } from './tts.js';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { isIP } from 'net'; import { isIP } from 'net';
import { Database } from './database.js';
import { MSAgentErrorMessage, MSAgentProtocolMessageType } from '@msagent-chat/protocol';
let config: IConfig; let config: IConfig;
let configPath: string; let configPath: string;
@ -31,6 +33,9 @@ try {
process.exit(1); process.exit(1);
} }
let db = new Database(config.mysql);
await db.init();
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
}); });
@ -77,10 +82,10 @@ app.get("/api/agents", (req, res) => {
return config.agents; return config.agents;
}); });
let room = new MSAgentChatRoom(config.chat, config.agents, tts); let room = new MSAgentChatRoom(config.chat, config.agents, db, tts);
app.register(async app => { app.register(async app => {
app.get("/api/socket", {websocket: true}, (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) {
@ -102,6 +107,18 @@ app.register(async app => {
} else { } else {
ip = req.ip; ip = req.ip;
} }
if (await db.isUserBanned(ip)) {
let msg: MSAgentErrorMessage = {
op: MSAgentProtocolMessageType.Error,
data: {
error: "You have been banned."
}
}
socket.send(JSON.stringify(msg), () => {
socket.close();
});
return;
}
let o = room.clients.filter(c => c.ip === ip); let o = room.clients.filter(c => c.ip === ip);
if (o.length >= config.chat.maxConnectionsPerIP) { if (o.length >= config.chat.maxConnectionsPerIP) {
o[0].socket.close(); o[0].socket.close();

View file

@ -1,8 +1,9 @@
import { MSAgentAddUserMessage, MSAgentChatMessage, MSAgentInitMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentRemoveUserMessage } from "@msagent-chat/protocol"; import { MSAgentAddUserMessage, MSAgentChatMessage, MSAgentInitMessage, MSAgentPromoteMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentRemoveUserMessage } from "@msagent-chat/protocol";
import { Client } from "./client.js"; import { Client } from "./client.js";
import { TTSClient } from "./tts.js"; import { TTSClient } from "./tts.js";
import { AgentConfig, ChatConfig } from "./config.js"; import { AgentConfig, ChatConfig } from "./config.js";
import * as htmlentities from 'html-entities'; import * as htmlentities from 'html-entities';
import { Database } from "./database.js";
export class MSAgentChatRoom { export class MSAgentChatRoom {
agents: AgentConfig[]; agents: AgentConfig[];
@ -10,12 +11,14 @@ export class MSAgentChatRoom {
tts: TTSClient | null; tts: TTSClient | null;
msgId : number = 0; msgId : number = 0;
config: ChatConfig; config: ChatConfig;
db: Database;
constructor(config: ChatConfig, agents: AgentConfig[], tts: TTSClient | null) { constructor(config: ChatConfig, agents: AgentConfig[], db: Database, tts: TTSClient | 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;
} }
addClient(client: Client) { addClient(client: Client) {
@ -76,6 +79,17 @@ export class MSAgentChatRoom {
_client.send(msg); _client.send(msg);
} }
}); });
client.on('admin', () => {
let msg: MSAgentPromoteMessage = {
op: MSAgentProtocolMessageType.Promote,
data: {
username: client.username!
}
};
for (const _client of this.getActiveClients()) {
_client.send(msg);
}
});
} }
private getActiveClients() { private getActiveClients() {

View file

@ -81,3 +81,32 @@ body {
font-weight: bold; font-weight: bold;
margin-right: 4px; margin-right: 4px;
} }
.context-menu {
background-color: silver;
ul {
list-style: none;
padding: 0;
margin: 0;
}
}
.context-menu-item {
padding-right: 0.2rem;
padding-left: 0.2rem;
font-size: 11px;
padding-bottom: 0.2rem;
padding-top: 0.2rem;
margin-top: 0.3rem;
margin-bottom: 0.3rem;
font-family: "Pixelated MS Sans Serif", Arial;
cursor: pointer;
}
.context-menu-item:hover {
background-color: navy;
color: #fff;
}

View file

@ -29,7 +29,7 @@
</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="Optional"/> <input type="text" id="logonRoom" placeholder="Coming Soon" disabled/>
</div> </div>
<div id="logonButtonsContainer"> <div id="logonButtonsContainer">
<select id="agentSelect"> <select id="agentSelect">

View file

@ -1,16 +1,26 @@
import { createNanoEvents, Emitter, Unsubscribe } from 'nanoevents'; import { createNanoEvents, Emitter, Unsubscribe } from 'nanoevents';
import { import {
MSAgentAddUserMessage, MSAgentAddUserMessage,
MSAgentAdminBanMessage,
MSAgentAdminGetIPMessage,
MSAgentAdminGetIPResponse,
MSAgentAdminKickMessage,
MSAgentAdminLoginMessage,
MSAgentAdminLoginResponse,
MSAgentAdminMessage,
MSAgentAdminOperation,
MSAgentChatMessage, MSAgentChatMessage,
MSAgentErrorMessage,
MSAgentInitMessage, MSAgentInitMessage,
MSAgentJoinMessage, MSAgentJoinMessage,
MSAgentPromoteMessage,
MSAgentProtocolMessage, MSAgentProtocolMessage,
MSAgentProtocolMessageType, MSAgentProtocolMessageType,
MSAgentRemoveUserMessage, MSAgentRemoveUserMessage,
MSAgentTalkMessage MSAgentTalkMessage
} from '@msagent-chat/protocol'; } from '@msagent-chat/protocol';
import { User } from './user'; import { User } from './user';
import { agentCreateCharacterFromUrl } from '@msagent-chat/msagent.js'; import { agentCreateCharacterFromUrl, ContextMenuItem } from '@msagent-chat/msagent.js';
export interface MSAgentClientEvents { export interface MSAgentClientEvents {
close: () => void; close: () => void;
@ -32,6 +42,7 @@ export class MSAgentClient {
private users: User[]; private users: User[];
private playingAudio: Map<string, HTMLAudioElement> = new Map(); private playingAudio: Map<string, HTMLAudioElement> = new Map();
private charlimit: number = 0; private charlimit: number = 0;
private admin: boolean;
private username: string | null = null; private username: string | null = null;
private agentContainer: HTMLElement; private agentContainer: HTMLElement;
@ -43,6 +54,7 @@ export class MSAgentClient {
this.socket = null; this.socket = null;
this.events = createNanoEvents(); this.events = createNanoEvents();
this.users = []; this.users = [];
this.admin = false;
} }
on<E extends keyof MSAgentClientEvents>(event: E, callback: MSAgentClientEvents[E]): Unsubscribe { on<E extends keyof MSAgentClientEvents>(event: E, callback: MSAgentClientEvents[E]): Unsubscribe {
@ -54,6 +66,10 @@ export class MSAgentClient {
return (await res.json()) as APIAgentInfo[]; return (await res.json()) as APIAgentInfo[];
} }
getUsers() {
return this.users;
}
connect(): Promise<void> { connect(): Promise<void> {
return new Promise((res) => { return new Promise((res) => {
let url = new URL(this.url); let url = new URL(this.url);
@ -79,8 +95,6 @@ export class MSAgentClient {
}); });
this.socket.addEventListener('close', () => { this.socket.addEventListener('close', () => {
this.events.emit('close'); this.events.emit('close');
// TODO: Make this clean
window.location.reload();
}); });
}); });
} }
@ -128,6 +142,73 @@ export class MSAgentClient {
return this.charlimit; return this.charlimit;
} }
setContextMenu(user: User) {
let ctx = user.agent.getContextMenu();
ctx.clearItems();
// Mute
let _user = user;
let mute = new ContextMenuItem("Mute", () => {
if (_user.muted) {
mute.setName("Mute");
_user.muted = false;
} else {
mute.setName("Unmute");
_user.muted = true;
}
});
ctx.addItem(mute);
// Admin
if (this.admin) {
// Get IP
let getip = new ContextMenuItem("Get IP", () => {
let msg: MSAgentAdminGetIPMessage = {
op: MSAgentProtocolMessageType.Admin,
data: {
action: MSAgentAdminOperation.GetIP,
username: _user.username
}
};
this.send(msg);
});
ctx.addItem(getip);
// Kick
let kick = new ContextMenuItem("Kick", () => {
let msg: MSAgentAdminKickMessage = {
op: MSAgentProtocolMessageType.Admin,
data: {
action: MSAgentAdminOperation.Kick,
username: _user.username
}
};
this.send(msg);
});
ctx.addItem(kick);
// Ban
let ban = new ContextMenuItem("Ban", () => {
let msg: MSAgentAdminBanMessage = {
op: MSAgentProtocolMessageType.Admin,
data: {
action: MSAgentAdminOperation.Ban,
username: _user.username
}
};
this.send(msg);
});
ctx.addItem(ban);
}
}
async login(password: string) {
let msg: MSAgentAdminLoginMessage = {
op: MSAgentProtocolMessageType.Admin,
data: {
action: MSAgentAdminOperation.Login,
password
}
};
await this.send(msg);
}
private async handleMessage(data: string) { private async handleMessage(data: string) {
let msg: MSAgentProtocolMessage; let msg: MSAgentProtocolMessage;
try { try {
@ -144,22 +225,32 @@ 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); agent.setUsername(_user.username, "#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);
this.setContextMenu(user);
this.users.push(user); this.users.push(user);
} }
document.addEventListener('keydown', e => {
if (e.key === "l" && e.ctrlKey) {
e.preventDefault();
let password = window.prompt("Papers, please");
if (!password) return;
this.login(password);
}
});
this.events.emit('join'); this.events.emit('join');
break; break;
} }
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); 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);
this.setContextMenu(user);
this.users.push(user); this.users.push(user);
this.events.emit('adduser', user); this.events.emit('adduser', user);
break; break;
@ -180,6 +271,8 @@ export class MSAgentClient {
case MSAgentProtocolMessageType.Chat: { case MSAgentProtocolMessageType.Chat: {
let chatMsg = msg as MSAgentChatMessage; let chatMsg = msg as MSAgentChatMessage;
let user = this.users.find((u) => u.username === chatMsg.data.username); let user = this.users.find((u) => u.username === chatMsg.data.username);
if (user?.muted) return;
this.events.emit('chat', user, chatMsg.data.message); this.events.emit('chat', user, chatMsg.data.message);
if (chatMsg.data.audio !== undefined) { if (chatMsg.data.audio !== undefined) {
let audio = new Audio(this.url + chatMsg.data.audio); let audio = new Audio(this.url + chatMsg.data.audio);
@ -205,6 +298,41 @@ export class MSAgentClient {
} }
break; break;
} }
case MSAgentProtocolMessageType.Admin: {
let adminMsg = msg as MSAgentAdminMessage;
switch (adminMsg.data.action) {
case MSAgentAdminOperation.Login: {
let loginMsg = adminMsg as MSAgentAdminLoginResponse;
if (loginMsg.data.success) {
this.admin = true;
for (const user of this.users) this.setContextMenu(user);
} else {
alert("Incorrect password!");
}
break;
}
case MSAgentAdminOperation.GetIP: {
let ipMsg = adminMsg as MSAgentAdminGetIPResponse;
alert(`${ipMsg.data.username} - ${ipMsg.data.ip}`);
break;
}
}
break;
}
case MSAgentProtocolMessageType.Promote: {
let promoteMsg = msg as MSAgentPromoteMessage;
let user = this.users.find(u => u.username === promoteMsg.data.username);
if (!user) return;
user.admin = true;
user.agent.setUsername(user.username, "#ff0000");
break;
}
case MSAgentProtocolMessageType.Error: {
let errorMsg = msg as MSAgentErrorMessage;
// TODO: This should be shown as part of the logon window
window.alert(errorMsg.data.error);
break;
}
} }
} }
} }

View file

@ -16,7 +16,22 @@ const elements = {
chatSendBtn: document.getElementById("chatSendBtn") as HTMLButtonElement chatSendBtn: document.getElementById("chatSendBtn") as HTMLButtonElement
} }
let Room : MSAgentClient = new MSAgentClient(`${window.location.protocol}//${window.location.host}`, elements.chatView); let Room : MSAgentClient;
function roomInit() {
Room = new MSAgentClient(`${window.location.protocol}//${window.location.host}`, elements.chatView);
Room.on('close', () => {
for (let user of Room.getUsers()) {
user.agent.remove();
}
roomInit();
loggingIn = false;
elements.logonButton.disabled = false;
logonWindow.show();
elements.logonView.style.display = "block";
elements.chatView.style.display = "none";
});
}
let logonWindow = new MSWindow(elements.logonWindow, { let logonWindow = new MSWindow(elements.logonWindow, {
width: 500, width: 500,
@ -73,3 +88,5 @@ function talk() {
Room.talk(elements.chatInput.value); Room.talk(elements.chatInput.value);
elements.chatInput.value = ""; elements.chatInput.value = "";
} }
roomInit();

View file

@ -2,10 +2,14 @@ import { Agent } from "@msagent-chat/msagent.js";
export class User { export class User {
username: string; username: string;
agent: Agent agent: Agent;
muted: 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.admin = false;
} }
} }

View file

@ -233,6 +233,7 @@ __metadata:
"@types/ws": "npm:^8.5.10" "@types/ws": "npm:^8.5.10"
fastify: "npm:^4.28.1" fastify: "npm:^4.28.1"
html-entities: "npm:^2.5.2" html-entities: "npm:^2.5.2"
mysql2: "npm:^3.10.2"
toml: "npm:^3.0.0" toml: "npm:^3.0.0"
typescript: "npm:^5.5.3" typescript: "npm:^5.5.3"
ws: "npm:^8.17.1" ws: "npm:^8.17.1"
@ -1865,6 +1866,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"denque@npm:^2.1.0":
version: 2.1.0
resolution: "denque@npm:2.1.0"
checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363
languageName: node
linkType: hard
"depd@npm:2.0.0": "depd@npm:2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "depd@npm:2.0.0" resolution: "depd@npm:2.0.0"
@ -2245,6 +2253,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"generate-function@npm:^2.3.1":
version: 2.3.1
resolution: "generate-function@npm:2.3.1"
dependencies:
is-property: "npm:^1.0.2"
checksum: 10c0/4645cf1da90375e46a6f1dc51abc9933e5eafa4cd1a44c2f7e3909a30a4e9a1a08c14cd7d5b32da039da2dba2a085e1ed4597b580c196c3245b2d35d8bc0de5d
languageName: node
linkType: hard
"get-port@npm:^4.2.0": "get-port@npm:^4.2.0":
version: 4.2.0 version: 4.2.0
resolution: "get-port@npm:4.2.0" resolution: "get-port@npm:4.2.0"
@ -2401,7 +2418,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"iconv-lite@npm:^0.6.2": "iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3":
version: 0.6.3 version: 0.6.3
resolution: "iconv-lite@npm:0.6.3" resolution: "iconv-lite@npm:0.6.3"
dependencies: dependencies:
@ -2530,6 +2547,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"is-property@npm:^1.0.2":
version: 1.0.2
resolution: "is-property@npm:1.0.2"
checksum: 10c0/33ab65a136e4ba3f74d4f7d9d2a013f1bd207082e11cedb160698e8d5394644e873c39668d112a402175ccbc58a087cef87198ed46829dbddb479115a0257283
languageName: node
linkType: hard
"isexe@npm:^2.0.0": "isexe@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "isexe@npm:2.0.0" resolution: "isexe@npm:2.0.0"
@ -2767,6 +2791,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"long@npm:^5.2.1":
version: 5.2.3
resolution: "long@npm:5.2.3"
checksum: 10c0/6a0da658f5ef683b90330b1af76f06790c623e148222da9d75b60e266bbf88f803232dd21464575681638894a84091616e7f89557aa087fd14116c0f4e0e43d9
languageName: node
linkType: hard
"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0":
version: 10.3.0 version: 10.3.0
resolution: "lru-cache@npm:10.3.0" resolution: "lru-cache@npm:10.3.0"
@ -2774,6 +2805,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lru-cache@npm:^7.14.1":
version: 7.18.3
resolution: "lru-cache@npm:7.18.3"
checksum: 10c0/b3a452b491433db885beed95041eb104c157ef7794b9c9b4d647be503be91769d11206bb573849a16b4cc0d03cbd15ffd22df7960997788b74c1d399ac7a4fed
languageName: node
linkType: hard
"lru-cache@npm:^8.0.0":
version: 8.0.5
resolution: "lru-cache@npm:8.0.5"
checksum: 10c0/cd95a9c38497611c5a6453de39a881f6eb5865851a2a01b5f14104ff3fee515362a7b1e7de28606028f423802910ba05bdb8ae1aa7b0d54eae70c92f0cec10b2
languageName: node
linkType: hard
"make-fetch-happen@npm:^13.0.0": "make-fetch-happen@npm:^13.0.0":
version: 13.0.1 version: 13.0.1
resolution: "make-fetch-happen@npm:13.0.1" resolution: "make-fetch-happen@npm:13.0.1"
@ -3003,6 +3048,31 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mysql2@npm:^3.10.2":
version: 3.10.2
resolution: "mysql2@npm:3.10.2"
dependencies:
denque: "npm:^2.1.0"
generate-function: "npm:^2.3.1"
iconv-lite: "npm:^0.6.3"
long: "npm:^5.2.1"
lru-cache: "npm:^8.0.0"
named-placeholders: "npm:^1.1.3"
seq-queue: "npm:^0.0.5"
sqlstring: "npm:^2.3.2"
checksum: 10c0/7d7a0a703748fc7872b282893ebdda4ba34a4d888b5094e008fb7fd1d22c37afbba8b67d31faf9c43ef5a9ca3d242f866b54030aa2e419f6a3ef491268fc77ca
languageName: node
linkType: hard
"named-placeholders@npm:^1.1.3":
version: 1.1.3
resolution: "named-placeholders@npm:1.1.3"
dependencies:
lru-cache: "npm:^7.14.1"
checksum: 10c0/cd83b4bbdf358b2285e3c51260fac2039c9d0546632b8a856b3eeabd3bfb3d5b597507ab319b97c281a4a70d748f38bc66fa218a61cb44f55ad997ad5d9c9935
languageName: node
linkType: hard
"nanoevents@npm:^9.0.0": "nanoevents@npm:^9.0.0":
version: 9.0.0 version: 9.0.0
resolution: "nanoevents@npm:9.0.0" resolution: "nanoevents@npm:9.0.0"
@ -3595,6 +3665,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"seq-queue@npm:^0.0.5":
version: 0.0.5
resolution: "seq-queue@npm:0.0.5"
checksum: 10c0/ec870fc392f0e6e99ec0e551c3041c1a66144d1580efabae7358e572de127b0ad2f844c95a4861d2e6203f836adea4c8196345b37bed55331ead8f22d99ac84c
languageName: node
linkType: hard
"set-cookie-parser@npm:^2.4.1": "set-cookie-parser@npm:^2.4.1":
version: 2.6.0 version: 2.6.0
resolution: "set-cookie-parser@npm:2.6.0" resolution: "set-cookie-parser@npm:2.6.0"
@ -3734,6 +3811,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sqlstring@npm:^2.3.2":
version: 2.3.3
resolution: "sqlstring@npm:2.3.3"
checksum: 10c0/3b5dd7badb3d6312f494cfa6c9a381ee630fbe3dbd571c4c9eb8ecdb99a7bf5a1f7a5043191d768797f6b3c04eed5958ac6a5f948b998f0a138294c6d3125fbd
languageName: node
linkType: hard
"srcset@npm:4": "srcset@npm:4":
version: 4.0.0 version: 4.0.0
resolution: "srcset@npm:4.0.0" resolution: "srcset@npm:4.0.0"