From 675ef2c5a769ee39587ad9f580f4aed46307b4a0 Mon Sep 17 00:00:00 2001 From: gh0sTedBuddy Date: Sun, 7 Apr 2024 20:08:20 +0100 Subject: [PATCH] feat(plugins) Base plugins for rooms and permissions --- lib/WetSock/Plugins/PermissionPlugin.js | 150 ++++++++++++ lib/WetSock/Plugins/Rooms/Room.js | 124 ++++++++++ lib/WetSock/Plugins/Rooms/RoomPlugin.js | 306 ++++++++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 lib/WetSock/Plugins/PermissionPlugin.js create mode 100644 lib/WetSock/Plugins/Rooms/Room.js create mode 100644 lib/WetSock/Plugins/Rooms/RoomPlugin.js diff --git a/lib/WetSock/Plugins/PermissionPlugin.js b/lib/WetSock/Plugins/PermissionPlugin.js new file mode 100644 index 0000000..b8e8d58 --- /dev/null +++ b/lib/WetSock/Plugins/PermissionPlugin.js @@ -0,0 +1,150 @@ +/** + * Plugin for handling permissions. + */ +class PermissionsPlugin { + /** + * Predefined roles and their corresponding permissions. + */ + static defaultRoles = { + OWNER: 0o777, + ADMIN: 0o755, + MODERATOR: 0o644, + MEMBER: 0o444, + GUEST: 0o400, + }; + + /** + * Actions and their corresponding required permissions. + */ + static defaultActions = { + SEND_MESSAGE: 0o400, + MUTE_MEMBER: 0o200, + KICK_MEMBER: 0o200, + GRANT_PERMISSION: 0o100, + REVOKE_PERMISSION: 0o100, + ASSIGN_ROLE: 0o100, + }; + + /** + * Create a new instance of the PermissionsPlugin. + * @param {Object} [config={}] - The configuration object. + * @param {Object} [config.roles] - The custom roles and their corresponding permissions. + * @param {Object} [config.actions] - The custom actions and their corresponding required permissions. + */ + constructor(config = {}) { + this.roles = { + ...PermissionsPlugin.defaultRoles, + ...config.roles + }; + this.actions = { + ...PermissionsPlugin.defaultActions, + ...config.actions + }; + } + + /** + * Initialize the plugin. + * @param {Object} server - The server instance. + */ + initialize(server) { + this.server = server; + this.contexts = new Map(); + + // Listen for the 'permissions:init' event + server.on('permissions:init', (pluginId, context, initContext) => { + const key = pluginId + context; + this.contexts.set(key, new Map()); + initContext({ + get: (...args) => this.getPermissions(key, ...args), + has: (...args) => this.hasPermission(key, ...args), + can: (...args) => this.hasPermission(key, ...args), + add: (...args) => this.grantPermission(key, ...args), + remove: (...args) => this.revokePermission(key, ...args), + role: (...args) => this.assignRole(key, ...args), + }); + }); + } + + /** + * Get the permissions for the specified key and identifier. + * @param {string} key - The unique key identifying the plugin and context. + * @param {string} identifier - The identifier of the entity (e.g., user, member). + * @returns {Object} The permissions object. + */ + getPermissions(key, identifier) { + const context = this.contexts.get(key); + if (context) { + const permissions = context.get(identifier); + if (permissions) { + const result = {}; + for (const [action, requiredPermission] of Object.entries(this.actions)) { + result[action] = this.hasPermission(permissions, requiredPermission); + } + return result; + } + } + return {}; + } + + /** + * Check if the specified key and identifier has the given permission. + * @param {string} key - The unique key identifying the plugin and context. + * @param {string} identifier - The identifier of the entity (e.g., user, member). + * @param {string} action - The action to check permissions for. + * @returns {boolean} True if the identifier has the permission, false otherwise. + */ + hasPermission(key, identifier, action) { + const context = this.contexts.get(key); + if (context) { + const permissions = context.get(identifier); + if (permissions) { + const requiredPermission = this.actions[action]; + return (permissions & requiredPermission) === requiredPermission; + } + } + return false; + } + + /** + * Grant permission to the specified key and identifier for the given action. + * @param {string} key - The unique key identifying the plugin and context. + * @param {string} identifier - The identifier of the entity (e.g., user, member). + * @param {string} action - The action to grant permission for. + */ + grantPermission(key, identifier, action) { + const context = this.contexts.get(key); + if (context) { + const permissions = context.get(identifier) || 0; + context.set(identifier, permissions | this.actions[action]); + } + } + + /** + * Revoke permission from the specified key and identifier for the given action. + * @param {string} key - The unique key identifying the plugin and context. + * @param {string} identifier - The identifier of the entity (e.g., user, member). + * @param {string} action - The action to revoke permission for. + */ + revokePermission(key, identifier, action) { + const context = this.contexts.get(key); + if (context) { + const permissions = context.get(identifier); + if (permissions) { + context.set(identifier, permissions & ~this.actions[action]); + } + } + } + + /** + * Assign a role to the specified key and identifier. + * @param {string} key - The unique key identifying the plugin and context. + * @param {string} identifier - The identifier of the entity (e.g., user, member). + * @param {string} role - The role to assign. + */ + assignRole(key, identifier, role) { + const context = this.contexts.get(key); + if (context) { + context.set(identifier, this.roles[role]); + } + } +} diff --git a/lib/WetSock/Plugins/Rooms/Room.js b/lib/WetSock/Plugins/Rooms/Room.js new file mode 100644 index 0000000..499a5dd --- /dev/null +++ b/lib/WetSock/Plugins/Rooms/Room.js @@ -0,0 +1,124 @@ +/** + * Represents a chat room. + */ +class Room { + /** + * Create a new room instance. + * @param {string} id - The unique identifier of the room. + * @param {Object} [config={}] - The configuration options for the room. + */ + constructor(id, config = {}) { + this.id = id; + this.members = new Map(); + this.config = { + notifyMuteUnmute: config.notifyMuteUnmute || false, + notifyKick: config.notifyKick || false, + inactivityTimeout: config.inactivityTimeout || 60_000, + }; + } + + /** + * Add a member to the room. + * @param {string} memberId - The unique identifier of the member. + * @param {Object} memberData - Additional data associated with the member. + */ + add(memberId, memberData) { + this.members.set(memberId, { + ...memberData, + lastActivity: Date.now(), + }); + } + + /** + * Remove a member from the room. + * @param {string} memberId - The unique identifier of the member. + */ + remove(memberId) { + this.members.delete(memberId); + } + + /** + * Get the data of a member in the room. + * @param {string} memberId - The unique identifier of the member. + * @returns {Object|undefined} The member data or undefined if the member is not found. + */ + get(memberId) { + return this.members.get(memberId); + } + + /** + * Check if a member is muted in the room. + * @param {string} memberId - The unique identifier of the member. + * @returns {boolean} True if the member is muted, false otherwise. + */ + isMuted(memberId) { + const memberData = this.get(memberId); + return memberData ? memberData.muted : false; + } + + /** + * Check if a member is kicked from the room. + * @param {string} memberId - The unique identifier of the member. + * @returns {boolean} True if the member is kicked, false otherwise. + */ + isKicked(memberId) { + const memberData = this.get(memberId); + return memberData ? memberData.kicked : false; + } + + /** + * Mute a member in the room. + * @param {string} memberId - The unique identifier of the member. + */ + mute(memberId) { + const memberData = this.get(memberId); + if (memberData) { + memberData.muted = true; + } + } + + /** + * Unmute a member in the room. + * @param {string} memberId - The unique identifier of the member. + */ + unmute(memberId) { + const memberData = this.get(memberId); + if (memberData) { + memberData.muted = false; + } + } + + /** + * Kick a member from the room. + * @param {string} memberId - The unique identifier of the member. + */ + kick(memberId) { + const memberData = this.get(memberId); + if (memberData) { + memberData.kicked = Date.now(); + } + } + + /** + * Update the last activity timestamp of a member in the room. + * @param {string} memberId - The unique identifier of the member. + */ + update(memberId) { + const memberData = this.get(memberId); + if (memberData) { + memberData.lastActivity = Date.now(); + } + } + + /** + * Purge inactive members from the room based on the inactivity timeout. + */ + purge() { + const now = Date.now(); + for (const [memberId, memberData] of this.members.entries()) { + if (now - memberData.lastActivity > this.config.inactivityTimeout) { + this.kick(memberId); + } + } + } +} diff --git a/lib/WetSock/Plugins/Rooms/RoomPlugin.js b/lib/WetSock/Plugins/Rooms/RoomPlugin.js new file mode 100644 index 0000000..7d9b941 --- /dev/null +++ b/lib/WetSock/Plugins/Rooms/RoomPlugin.js @@ -0,0 +1,306 @@ +import BasePlugin from '../BasePlugin'; + +/** + * Plugin for handling room-related operations. + */ +export default class RoomPlugin extends BasePlugin { + #rooms = new Map(); + #clients = new Map(); + #config = { + allowMultipleRooms: true, + alwaysMainChannel: false, + notifyMuteUnmute: false, + notifyKick: false, + inactivityTimeout: 60_000, + }; + + /** + * Initialize the plugin. + * @param {Object} wetsock - The WetSock server instance. + * @param {Object} config - The configuration options for the plugin. + */ + initialize(wetsock, config = {}) { + super.initialize(wetsock); + this.#config = { ...this.#config, ...config }; + wetsock.on('message', this.onMessage.bind(this)); + wetsock.on('close', this.onClose.bind(this)); + setInterval(this.#purge.bind(this), this.#config.inactivityTimeout); + } + + /** + * Handle incoming messages from clients. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @returns {boolean} - True if the message was handled, false otherwise. + */ + onMessage(client, message) { + if (typeof message === 'object' && message.type && this[message.type]) { + return this[message.type](client, message); + } + return false; + } + + /** + * Handle a client disconnecting. + * @param {Object} client - The client object. + */ + onClose(client) { + const clientRooms = this.#clients.get(client.id); + if (clientRooms) { + for (const roomId of clientRooms) { + const room = this.#rooms.get(roomId); + if (room) { + room.members.delete(client.id); + this._getServer().publish(roomId, `${client.id} has left the room`); + } + } + this.#clients.delete(client.id); + } + } + + actions () { + return Object.getOwnPropertyNames( + Object.getPrototypeOf(this) + ).filter((prop) => { + return ![ + "constructor", + "initialize", + "onMessage", + "onClose", + "actions" + ].includes(prop) && typeof this[prop] === 'function' + }); + } + + /** + * Handle a client joining a room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room to join. + */ + join(client, { roomId }) { + const room = this.#rooms.get(roomId); + if (room) { + const memberData = room.members.get(client.id); + if (memberData && memberData.kicked) { + client.send({ type: 'error', message: 'You have been kicked from this room and cannot rejoin' }); + return; + } + if (!this.#config.allowMultipleRooms && this.#clients.has(client.id)) { + client.send({ type: 'error', message: 'You can only be in one room at a time' }); + return; + } + client.ws.subscribe(roomId); + room.add(client.id, { + muted: memberData ? memberData.muted : false, + lastActivity: Date.now() + }); + this.#clients.set(client.id, (this.#clients.get(client.id) || new Set()).add(roomId)); + this._getServer().publish(roomId, `${client.id} has joined the room`); + } else { + client.send({ type: 'error', message: `Room ${roomId} does not exist` }); + } + } + + /** + * Handle a client leaving a room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room to leave. + */ + leave(client, { roomId }) { + const room = this.#rooms.get(roomId); + if (room) { + client.ws.unsubscribe(roomId); + room.members.delete(client.id); + const clientRooms = this.#clients.get(client.id); + if (clientRooms) { + clientRooms.delete(roomId); + if (clientRooms.size === 0 && !this.#config.alwaysMainChannel) { + this.#clients.delete(client.id); + } + } + this._getServer().publish(roomId, `${client.id} has left the room`); + } else { + client.send({ type: 'error', message: `Room ${roomId} does not exist` }); + } + } + + /** + * Handle creating a new room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room to create. + */ + create(client, { roomId }) { + if (this.#rooms.has(roomId)) { + client.send({ type: 'error', message: `Room ${roomId} already exists` }); + return; + } + const room = new Room(roomId); + this.#rooms.set(roomId, room); + client.ws.subscribe(roomId); + room.add(client.id, { muted: false, lastActivity: Date.now() }); + this.#clients.set(client.id, (this.#clients.get(client.id) || new Set()).add(roomId)); + + // Initialize the permissions for the room + this._getServer().emit('permissions:init', this.constructor.name, roomId, (permissions) => { + room.permissions = permissions; + permissions.role(client.id, 'OWNER'); + }); + + client.send({ type: 'success', message: `Room ${roomId} created successfully` }); + } + + /** + * Handle sending a message to a room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room to send the message to. + * @param {string} message.content - The content of the message. + * @returns {boolean} - True if the message was sent successfully, false otherwise. + */ + send(client, { roomId, content }) { + const room = this.#rooms.get(roomId); + if (room && room.get(client.id)) { + if (!room.permissions.can(client.id, 'SEND_MESSAGE')) { + client.send({ type: 'error', message: 'You do not have permission to send messages in this room' }); + return false; + } + const memberData = room.members.get(client.id); + if (memberData.muted) { + client.send({ type: 'error', message: 'You are muted and cannot send messages in this room' }); + return false; + } + room.update(client.id); + this._getServer().publish(roomId, `${client.id}: ${content}`); + this.emit('rooms:message', client, message.roomId, message.content); + this.emit(`rooms:message:${message.roomId}`, client, message.content); + return true; + } else { + client.send({ type: 'error', message: 'You are not allowed to send messages in this room' }); + return false; + } + } + + /** + * Handle kicking a member from a room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room. + * @param {string} message.memberId - The ID of the member to kick. + */ + kick(client, { roomId, memberId }) { + const room = this.#rooms.get(roomId); + if (room && room.get(memberId)) { + if (!room.permissions.can(client.id, 'KICK_MEMBER')) { + client.send({ type: 'error', message: 'You do not have permission to kick members from this room' }); + return; + } + + const member = this._getServer().getValidClient(memberId); + if (member) { + member.ws.unsubscribe(roomId); + room.members.set(memberId, { id: memberId, muted: true, kicked: Date.now() }); + const clientRooms = this.#clients.get(memberId); + if (clientRooms) { + clientRooms.delete(roomId); + if (clientRooms.size === 0 && !this.#config.alwaysMainChannel) { + this.#clients.delete(memberId); + } + } + if (this.#config.notifyKick) { + this._getServer().publish(roomId, `${memberId} has been kicked from the room`); + } + member.send({ type: 'kicked', roomId }); + } + client.send({ type: 'success', message: `Member ${memberId} has been kicked from room ${roomId}` }); + } else { + client.send({ type: 'error', message: `Cannot kick member ${memberId} from room ${roomId}` }); + } + } + + /** + * Handle muting a member in a room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room. + * @param {string} message.memberId - The ID of the member to mute. + */ + mute(client, { roomId, memberId }) { + const room = this.#rooms.get(roomId); + if (room && room.get(memberId)) { + if (!room.permissions.can(client.id, 'MUTE_MEMBER')) { + client.send({ type: 'error', message: 'You do not have permission to mute members in this room' }); + return; + } + + room.members.get(memberId).muted = true; + const member = this._getServer().getValidClient(memberId); + if (member && this.#config.notifyMuteUnmute) { + member.send({ type: 'muted', roomId }); + } + client.send({ type: 'success', message: `Member ${memberId} has been muted in room ${roomId}` }); + } else { + client.send({ type: 'error', message: `Cannot mute member ${memberId} in room ${roomId}` }); + } + } + + /** + * Handle unmuting a member in a room. + * @param {Object} client - The client object. + * @param {Object} message - The message object. + * @param {string} message.roomId - The ID of the room. + * @param {string} message.memberId - The ID of the member to unmute. + */ + unmute(client, { roomId, memberId }) { + const room = this.#rooms.get(roomId); + if (room && room.get(memberId)) { + if (!room.permissions.can(client.id, 'UNMUTE_MEMBER')) { + client.send({ type: 'error', message: 'You do not have permission to unmute members in this room' }); + return; + } + + room.members.get(memberId).muted = false; + const member = this._getServer().getValidClient(memberId); + if (member && this.#config.notifyMuteUnmute) { + member.send({ type: 'unmuted', roomId }); + } + client.send({ type: 'success', message: `Member ${memberId} has been unmuted in room ${roomId}` }); + } else { + client.send({ type: 'error', message: `Cannot unmute member ${memberId} in room ${roomId}` }); + } + } + + /** + * Check for inactive members in each room and kick them. + */ + #purge() { + for (const [roomId, room] of this.#rooms.entries()) { + const now = Date.now(); + for (const [memberId, memberData] of room.members.entries()) { + if (now - memberData.lastActivity > this.#config.inactivityTimeout) { + this.kick(null, { roomId, memberId }); + } + } + } + } + + /** + * Broadcast a message to a specific room or all rooms. + * @param {string} roomId - The ID of the room to broadcast to. If not provided, the message will be broadcast to all rooms. + * @param {string} message - The message to broadcast. + */ + #broadcast(message, roomId) { + if (this.#rooms.has(roomId)) { + this._getServer().publish(roomId, message); + return; + } + + if(roomId == 'all') { + for (const room of this.#rooms.values()) { + this._getServer().publish(room.id, message); + } + } + } +}