Add admin features (get ip, kick, ban), context menu, ban database, clean disconnect and reconnect, and errors.
This commit is contained in:
parent
78629b9cb7
commit
3525fdcff4
19 changed files with 626 additions and 25 deletions
|
@ -1,5 +1,6 @@
|
|||
import { BufferStream, SeekDir } from './buffer.js';
|
||||
import { AcsData } from './character.js';
|
||||
import { ContextMenu, ContextMenuItem } from './contextmenu.js';
|
||||
import { AcsAnimation, AcsAnimationFrameInfo } from './structs/animation.js';
|
||||
import { AcsImageEntry } from './structs/image.js';
|
||||
import { Point, Size } from './types.js';
|
||||
|
@ -69,7 +70,7 @@ class AgentWordBalloonState {
|
|||
balloonCanvas: HTMLCanvasElement;
|
||||
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.text = text;
|
||||
this.hasTip = hasTip;
|
||||
|
@ -87,13 +88,13 @@ class AgentWordBalloonState {
|
|||
// hack fix for above
|
||||
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
|
||||
this.balloonCanvas.width = rect.w;
|
||||
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);
|
||||
|
||||
|
@ -142,6 +143,8 @@ export class Agent {
|
|||
private wordballoonState: AgentWordBalloonState | null = null;
|
||||
private usernameBalloonState: AgentWordBalloonState | null = null;
|
||||
|
||||
private contextMenu: ContextMenu;
|
||||
|
||||
constructor(data: AcsData) {
|
||||
this.data = data;
|
||||
this.charDiv = document.createElement('div');
|
||||
|
@ -155,6 +158,8 @@ export class Agent {
|
|||
this.cnv.height = data.characterInfo.charHeight;
|
||||
this.cnv.style.display = 'none';
|
||||
|
||||
this.contextMenu = new ContextMenu(this.charDiv);
|
||||
|
||||
this.charDiv.appendChild(this.cnv);
|
||||
|
||||
this.dragging = false;
|
||||
|
@ -173,7 +178,7 @@ export class Agent {
|
|||
});
|
||||
this.cnv.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
// TODO: Custom context menu support
|
||||
this.contextMenu.show(e.clientX, e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.dragging) return;
|
||||
|
@ -201,6 +206,10 @@ export class Agent {
|
|||
return this.charDiv;
|
||||
}
|
||||
|
||||
getContextMenu() {
|
||||
return this.contextMenu;
|
||||
}
|
||||
|
||||
getAt() {
|
||||
let point: Point = {
|
||||
x: this.x,
|
||||
|
@ -284,13 +293,13 @@ export class Agent {
|
|||
if (index !== -1) this.playAnimation(index, finishCallback);
|
||||
}
|
||||
|
||||
setUsername(username: string) {
|
||||
setUsername(username: string, color: string) {
|
||||
if (this.usernameBalloonState !== null) {
|
||||
this.usernameBalloonState.finish();
|
||||
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();
|
||||
}
|
||||
|
||||
|
@ -299,7 +308,7 @@ export class Agent {
|
|||
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.show();
|
||||
}
|
||||
|
|
80
msagent.js/src/contextmenu.ts
Normal file
80
msagent.js/src/contextmenu.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ 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.
|
||||
|
|
|
@ -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.
|
||||
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);
|
||||
|
||||
// Create metrics for each line
|
||||
|
@ -167,6 +167,7 @@ export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, te
|
|||
let metric = metrics[i];
|
||||
let height = metric.actualBoundingBoxAscent + metric.actualBoundingBoxDescent;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(lines[i], rectInner.x - 12, rectInner.y + y);
|
||||
y += height * 1.25;
|
||||
}
|
||||
|
|
64
protocol/src/admin.ts
Normal file
64
protocol/src/admin.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -1,12 +1,17 @@
|
|||
export * from './admin.js';
|
||||
|
||||
export enum MSAgentProtocolMessageType {
|
||||
// Client-to-server
|
||||
Join = "join",
|
||||
Talk = "talk",
|
||||
Admin = "admin",
|
||||
// Server-to-client
|
||||
Init = "init",
|
||||
AddUser = "adduser",
|
||||
RemoveUser = "remuser",
|
||||
Chat = "chat"
|
||||
Chat = "chat",
|
||||
Promote = "promote",
|
||||
Error = "error"
|
||||
}
|
||||
|
||||
export interface MSAgentProtocolMessage {
|
||||
|
@ -68,3 +73,17 @@ export interface MSAgentChatMessage extends MSAgentProtocolMessage {
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -3,10 +3,17 @@ host = "127.0.0.1"
|
|||
port = 3000
|
||||
proxied = true
|
||||
|
||||
[mysql]
|
||||
host = "127.0.0.1"
|
||||
username = "agentchat"
|
||||
password = "hunter2"
|
||||
database = "agentchat"
|
||||
|
||||
[chat]
|
||||
charlimit = 100
|
||||
agentsDir = "agents/"
|
||||
maxConnectionsPerIP = 2
|
||||
adminPasswordHash = "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7"
|
||||
|
||||
[chat.ratelimits]
|
||||
chat = {seconds = 10, limit = 8}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"@fastify/websocket": "^10.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
"html-entities": "^2.5.2",
|
||||
"mysql2": "^3.10.2",
|
||||
"toml": "^3.0.0",
|
||||
"ws": "^8.17.1"
|
||||
},
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import EventEmitter from "events";
|
||||
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 * as htmlentities from 'html-entities';
|
||||
import RateLimiter from "./ratelimiter.js";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
// Event types
|
||||
|
||||
|
@ -19,6 +20,7 @@ export class Client extends EventEmitter {
|
|||
ip: string;
|
||||
username: string | null;
|
||||
agent: string | null;
|
||||
admin: boolean;
|
||||
|
||||
room: MSAgentChatRoom;
|
||||
socket: WebSocket;
|
||||
|
@ -32,6 +34,7 @@ export class Client extends EventEmitter {
|
|||
this.room = room;
|
||||
this.username = null;
|
||||
this.agent = null;
|
||||
this.admin = false;
|
||||
|
||||
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;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
|
@ -105,6 +108,83 @@ export class Client extends EventEmitter {
|
|||
this.emit('talk', talkMsg.data.msg);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ export interface IConfig {
|
|||
port: number;
|
||||
proxied: boolean;
|
||||
}
|
||||
mysql: MySQLConfig;
|
||||
chat: ChatConfig;
|
||||
tts: TTSConfig;
|
||||
agents: AgentConfig[];
|
||||
|
@ -21,6 +22,7 @@ export interface ChatConfig {
|
|||
charlimit: number;
|
||||
agentsDir: string;
|
||||
maxConnectionsPerIP: number;
|
||||
adminPasswordHash: string;
|
||||
ratelimits: {
|
||||
chat: RateLimitConfig;
|
||||
}
|
||||
|
@ -32,8 +34,14 @@ export interface AgentConfig {
|
|||
}
|
||||
|
||||
|
||||
|
||||
export interface RateLimitConfig {
|
||||
seconds: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface MySQLConfig {
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
}
|
38
server/src/database.ts
Normal file
38
server/src/database.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ import { TTSClient } from './tts.js';
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { isIP } from 'net';
|
||||
import { Database } from './database.js';
|
||||
import { MSAgentErrorMessage, MSAgentProtocolMessageType } from '@msagent-chat/protocol';
|
||||
|
||||
let config: IConfig;
|
||||
let configPath: string;
|
||||
|
@ -31,6 +33,9 @@ try {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
let db = new Database(config.mysql);
|
||||
await db.init();
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
@ -77,10 +82,10 @@ app.get("/api/agents", (req, res) => {
|
|||
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.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
|
||||
let ip: string;
|
||||
if (config.http.proxied) {
|
||||
|
@ -102,6 +107,18 @@ app.register(async app => {
|
|||
} else {
|
||||
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);
|
||||
if (o.length >= config.chat.maxConnectionsPerIP) {
|
||||
o[0].socket.close();
|
||||
|
|
|
@ -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 { TTSClient } from "./tts.js";
|
||||
import { AgentConfig, ChatConfig } from "./config.js";
|
||||
import * as htmlentities from 'html-entities';
|
||||
import { Database } from "./database.js";
|
||||
|
||||
export class MSAgentChatRoom {
|
||||
agents: AgentConfig[];
|
||||
|
@ -10,12 +11,14 @@ export class MSAgentChatRoom {
|
|||
tts: TTSClient | null;
|
||||
msgId : number = 0;
|
||||
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.clients = [];
|
||||
this.config = config;
|
||||
this.tts = tts;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
addClient(client: Client) {
|
||||
|
@ -76,6 +79,17 @@ export class MSAgentChatRoom {
|
|||
_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() {
|
||||
|
|
|
@ -81,3 +81,32 @@ body {
|
|||
font-weight: bold;
|
||||
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;
|
||||
}
|
|
@ -29,7 +29,7 @@
|
|||
</div>
|
||||
<div id="logonRoomContainer">
|
||||
<label for="logonRoom">Room name:</label>
|
||||
<input type="text" id="logonRoom" placeholder="Optional"/>
|
||||
<input type="text" id="logonRoom" placeholder="Coming Soon" disabled/>
|
||||
</div>
|
||||
<div id="logonButtonsContainer">
|
||||
<select id="agentSelect">
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
import { createNanoEvents, Emitter, Unsubscribe } from 'nanoevents';
|
||||
import {
|
||||
MSAgentAddUserMessage,
|
||||
MSAgentAdminBanMessage,
|
||||
MSAgentAdminGetIPMessage,
|
||||
MSAgentAdminGetIPResponse,
|
||||
MSAgentAdminKickMessage,
|
||||
MSAgentAdminLoginMessage,
|
||||
MSAgentAdminLoginResponse,
|
||||
MSAgentAdminMessage,
|
||||
MSAgentAdminOperation,
|
||||
MSAgentChatMessage,
|
||||
MSAgentErrorMessage,
|
||||
MSAgentInitMessage,
|
||||
MSAgentJoinMessage,
|
||||
MSAgentPromoteMessage,
|
||||
MSAgentProtocolMessage,
|
||||
MSAgentProtocolMessageType,
|
||||
MSAgentRemoveUserMessage,
|
||||
MSAgentTalkMessage
|
||||
} from '@msagent-chat/protocol';
|
||||
import { User } from './user';
|
||||
import { agentCreateCharacterFromUrl } from '@msagent-chat/msagent.js';
|
||||
import { agentCreateCharacterFromUrl, ContextMenuItem } from '@msagent-chat/msagent.js';
|
||||
|
||||
export interface MSAgentClientEvents {
|
||||
close: () => void;
|
||||
|
@ -32,6 +42,7 @@ export class MSAgentClient {
|
|||
private users: User[];
|
||||
private playingAudio: Map<string, HTMLAudioElement> = new Map();
|
||||
private charlimit: number = 0;
|
||||
private admin: boolean;
|
||||
|
||||
private username: string | null = null;
|
||||
private agentContainer: HTMLElement;
|
||||
|
@ -43,6 +54,7 @@ export class MSAgentClient {
|
|||
this.socket = null;
|
||||
this.events = createNanoEvents();
|
||||
this.users = [];
|
||||
this.admin = false;
|
||||
}
|
||||
|
||||
on<E extends keyof MSAgentClientEvents>(event: E, callback: MSAgentClientEvents[E]): Unsubscribe {
|
||||
|
@ -54,6 +66,10 @@ export class MSAgentClient {
|
|||
return (await res.json()) as APIAgentInfo[];
|
||||
}
|
||||
|
||||
getUsers() {
|
||||
return this.users;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((res) => {
|
||||
let url = new URL(this.url);
|
||||
|
@ -79,8 +95,6 @@ export class MSAgentClient {
|
|||
});
|
||||
this.socket.addEventListener('close', () => {
|
||||
this.events.emit('close');
|
||||
// TODO: Make this clean
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -128,6 +142,73 @@ export class MSAgentClient {
|
|||
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) {
|
||||
let msg: MSAgentProtocolMessage;
|
||||
try {
|
||||
|
@ -144,22 +225,32 @@ export class MSAgentClient {
|
|||
this.charlimit = initMsg.data.charlimit;
|
||||
for (let _user of initMsg.data.users) {
|
||||
let agent = await agentCreateCharacterFromUrl(this.url + '/api/agents/' + _user.agent);
|
||||
agent.setUsername(_user.username);
|
||||
agent.setUsername(_user.username, "#000000");
|
||||
agent.addToDom(this.agentContainer);
|
||||
agent.show();
|
||||
let user = new User(_user.username, agent);
|
||||
this.setContextMenu(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');
|
||||
break;
|
||||
}
|
||||
case MSAgentProtocolMessageType.AddUser: {
|
||||
let addUserMsg = msg as MSAgentAddUserMessage;
|
||||
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.show();
|
||||
let user = new User(addUserMsg.data.username, agent);
|
||||
this.setContextMenu(user);
|
||||
this.users.push(user);
|
||||
this.events.emit('adduser', user);
|
||||
break;
|
||||
|
@ -180,6 +271,8 @@ export class MSAgentClient {
|
|||
case MSAgentProtocolMessageType.Chat: {
|
||||
let chatMsg = msg as MSAgentChatMessage;
|
||||
let user = this.users.find((u) => u.username === chatMsg.data.username);
|
||||
if (user?.muted) return;
|
||||
|
||||
this.events.emit('chat', user, chatMsg.data.message);
|
||||
if (chatMsg.data.audio !== undefined) {
|
||||
let audio = new Audio(this.url + chatMsg.data.audio);
|
||||
|
@ -205,6 +298,41 @@ export class MSAgentClient {
|
|||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,22 @@ const elements = {
|
|||
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, {
|
||||
width: 500,
|
||||
|
@ -73,3 +88,5 @@ function talk() {
|
|||
Room.talk(elements.chatInput.value);
|
||||
elements.chatInput.value = "";
|
||||
}
|
||||
|
||||
roomInit();
|
|
@ -2,10 +2,14 @@ import { Agent } from "@msagent-chat/msagent.js";
|
|||
|
||||
export class User {
|
||||
username: string;
|
||||
agent: Agent
|
||||
agent: Agent;
|
||||
muted: boolean;
|
||||
admin: boolean;
|
||||
|
||||
constructor(username: string, agent: Agent) {
|
||||
this.username = username;
|
||||
this.agent = agent;
|
||||
this.muted = false;
|
||||
this.admin = false;
|
||||
}
|
||||
}
|
86
yarn.lock
86
yarn.lock
|
@ -233,6 +233,7 @@ __metadata:
|
|||
"@types/ws": "npm:^8.5.10"
|
||||
fastify: "npm:^4.28.1"
|
||||
html-entities: "npm:^2.5.2"
|
||||
mysql2: "npm:^3.10.2"
|
||||
toml: "npm:^3.0.0"
|
||||
typescript: "npm:^5.5.3"
|
||||
ws: "npm:^8.17.1"
|
||||
|
@ -1865,6 +1866,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 2.0.0
|
||||
resolution: "depd@npm:2.0.0"
|
||||
|
@ -2245,6 +2253,15 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.2.0
|
||||
resolution: "get-port@npm:4.2.0"
|
||||
|
@ -2401,7 +2418,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"iconv-lite@npm:^0.6.2":
|
||||
"iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3":
|
||||
version: 0.6.3
|
||||
resolution: "iconv-lite@npm:0.6.3"
|
||||
dependencies:
|
||||
|
@ -2530,6 +2547,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 2.0.0
|
||||
resolution: "isexe@npm:2.0.0"
|
||||
|
@ -2767,6 +2791,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 10.3.0
|
||||
resolution: "lru-cache@npm:10.3.0"
|
||||
|
@ -2774,6 +2805,20 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 13.0.1
|
||||
resolution: "make-fetch-happen@npm:13.0.1"
|
||||
|
@ -3003,6 +3048,31 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 9.0.0
|
||||
resolution: "nanoevents@npm:9.0.0"
|
||||
|
@ -3595,6 +3665,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 2.6.0
|
||||
resolution: "set-cookie-parser@npm:2.6.0"
|
||||
|
@ -3734,6 +3811,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.0.0
|
||||
resolution: "srcset@npm:4.0.0"
|
||||
|
|
Loading…
Reference in a new issue