From c00017a6021d9548a4c8eef9353644965d6a75dc Mon Sep 17 00:00:00 2001 From: TanPat Date: Wed, 3 Dec 2025 04:10:31 +0530 Subject: [PATCH 1/6] feat: add joinphrases module - add database model and CRUD methods - add commands for adding, getting, and deleting joinphrases - add logic to send joinphrases on user join --- src/cache/index.ts | 1 + src/database/joinphrases.ts | 87 ++++++++++++++++++++++++++++ src/ps/commands/joinphrases.ts | 103 +++++++++++++++++++++++++++++++++ src/ps/handlers/joins.ts | 9 ++- src/ps/loaders/index.ts | 2 + src/ps/loaders/joinphrases.ts | 12 ++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/database/joinphrases.ts create mode 100644 src/ps/commands/joinphrases.ts create mode 100644 src/ps/loaders/joinphrases.ts diff --git a/src/cache/index.ts b/src/cache/index.ts index b553c625..0d3a730b 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -21,6 +21,7 @@ export const PSCommands: { [key: string]: PSCommand & { path: string } } = {}; */ export const PSAliases: { [key: string]: string } = {}; export const PSAltCache: Partial<{ [key: string]: { from: string; to: string; at: Date } }> = {}; +export const PSJoinphraseCache: Partial<{ [key: string]: { id: string; phrase: string } }> = {}; export const PSSeenCache: Partial<{ [key: string]: { id: string; name: string; at: Date; seenIn: string[] } }> = {}; export const PSCronJobs: { manager: PSCronJobManager | null } = { manager: null }; diff --git a/src/database/joinphrases.ts b/src/database/joinphrases.ts new file mode 100644 index 00000000..ff04b7e8 --- /dev/null +++ b/src/database/joinphrases.ts @@ -0,0 +1,87 @@ +import mongoose from 'mongoose'; + +import { username } from '@/config/ps'; +import { IS_ENABLED } from '@/enabled'; +import { toId } from '@/utils/toId'; + +interface Model { + id: string; // * Unique field of form "userId-roomId" + username: string; + userId: string; + roomId: string; + phrase: string; + addedBy: string; + at: Date; +} + +const schema = new mongoose.Schema({ + id: { + type: String, + required: true, + unique: true, + }, + username: { + type: String, + required: true, + }, + userId: { + type: String, + default: toId(username), + }, + roomId: { + type: String, + required: true, + }, + phrase: { + type: String, + default: '', + }, + addedBy: { + type: String, + required: true, + }, + at: { + type: Date, + default: Date.now, + }, +}); + +const model = mongoose.model('joinphrase', schema, 'joinphrases', { overwriteModels: true }); + +export async function addJoinphrase(username: string, roomId: string, phrase: string, by: string): Promise { + if (!IS_ENABLED.DB) return null; + const userId = toId(username); + return model.create({ + id: `${userId}-${roomId}`, + username, + userId, + roomId: roomId, + phrase, + addedBy: by, + }); +} + +export async function getJoinphrase(username: string, roomId: string): Promise<{ phrase: string } | null> { + if (!IS_ENABLED.DB) return null; + + const id = `${toId(username)}-${roomId}`; + return await model.findOne({ id }, { phrase: 1, _id: 0 }).lean(); +} + +export async function fetchAllJoinphrases(): Promise { + if (!IS_ENABLED.DB) return []; + return model.find({}).lean(); +} + +export async function deleteJoinphrase(username: string, roomId: string): Promise { + if (!IS_ENABLED.DB) return null; + + const id = `${toId(username)}-${roomId}`; + const toDelete = await model.findOne({ id }); + + if (!toDelete) { + return null; + } + await toDelete.deleteOne(); + return toDelete.toObject(); +} diff --git a/src/ps/commands/joinphrases.ts b/src/ps/commands/joinphrases.ts new file mode 100644 index 00000000..198bd03d --- /dev/null +++ b/src/ps/commands/joinphrases.ts @@ -0,0 +1,103 @@ +import { addJoinphrase, deleteJoinphrase, getJoinphrase } from '@/database/joinphrases'; +import { ChatError } from '@/utils/chatError'; + +import type { NoTranslate, ToTranslate } from '@/i18n/types'; +import type { PSCommand } from '@/types/chat'; + +export const command: PSCommand = { + name: 'jp', + help: 'Joinphrases module!', + perms: ['room', 'driver'], + syntax: 'CMD', + aliases: ['joinphrase'], + categories: ['utility'], + extendedAliases: { + addjp: ['jp', 'new'], + deletejp: ['jp', 'delete'], + removejp: ['jp', 'delete'], + remjp: ['jp', 'delete'], + getjp: ['jp', 'get'], + showjp: ['jp', 'get'], + displayjp: ['jp', 'get'], + }, + children: { + new: { + name: 'new', + help: 'Adds a new joinphrase for a given user', + perms: ['room', 'driver'], + flags: { allowPMs: false }, + syntax: 'CMD [User], [Joinphrase]', + aliases: ['add', 'a', 'n'], + async run({ message, arg, $T, checkPermissions }) { + if (!checkPermissions(['room', 'driver'])) { + throw new ChatError($T('ACCESS_DENIED')); + } + if (!arg) { + message.reply($T('INVALID_ARGUMENTS')); + } + const args: string[] = arg.split(',').map(s => s.trim()); + const username = args[0]; + const phrase = args[1]; + if (!phrase) { + message.reply('Put both username and message smh. Try again.' as ToTranslate); + return; + } + + try { + await addJoinphrase(username, message.target.id, phrase, message.author.name); + message.reply('Joinphrase Added!' as ToTranslate); + } catch (e: unknown) { + message.reply(`${username} already has a joinphrase in ${message.target.title}...` as ToTranslate); + } + }, + }, + delete: { + name: 'delete', + help: "Deletes a user's joinphrase", + perms: ['room', 'driver'], + syntax: 'CMD [User]', + aliases: ['del', 'remove', 'rem', 'd', 'r'], + async run({ message, arg, $T, checkPermissions }) { + if (!checkPermissions(['room', 'driver'])) { + throw new ChatError($T('ACCESS_DENIED')); + } + if (!arg) { + message.reply($T('INVALID_ARGUMENTS')); + return; + } + arg.trim(); + + if ((await deleteJoinphrase(arg, message.target.id)) === null) { + message.reply(`${arg} has no joinphrase in this room!` as ToTranslate); + } else { + message.reply('Joinphrase deleted.' as ToTranslate); + } + }, + }, + get: { + name: 'get', + help: 'Displays a given joinphrase', + perms: ['room', 'driver'], + syntax: 'CMD [User]', + aliases: ['show', 'display'], + async run({ message, arg, $T, checkPermissions }) { + if (!checkPermissions(['room', 'driver'])) { + throw new ChatError($T('ACCESS_DENIED')); + } + if (!arg) { + message.reply($T('INVALID_ARGUMENTS')); + return; + } + arg.trim(); + + const phraseObject = await getJoinphrase(arg, message.target.id); + const phrase = phraseObject?.phrase; + + message.privateReply(`${phrase}` as NoTranslate); + }, + }, + }, + async run({ arg, run }) { + return await run(`jp get ${arg}`); + }, +}; diff --git a/src/ps/handlers/joins.ts b/src/ps/handlers/joins.ts index f270c233..70827584 100644 --- a/src/ps/handlers/joins.ts +++ b/src/ps/handlers/joins.ts @@ -1,4 +1,4 @@ -import { PSAltCache, PSGames, PSSeenCache } from '@/cache'; +import { PSAltCache, PSGames, PSJoinphraseCache, PSSeenCache } from '@/cache'; import { rename } from '@/database/alts'; import { seeUser } from '@/database/seens'; import { ChatError } from '@/utils/chatError'; @@ -13,6 +13,11 @@ export function joinHandler(this: Client, room: string, user: string, isIntro: b // Joinphrases // 'Stalking' // (Kinda creepy name for the feature, but it CAN be used in creepy ways so make sure it's regulated!) + // TODO Add minimum-messages-sent and minimum-time-since-last-occurence conditions + const userId = toId(user), + roomId = toId(room); + const phrase = PSJoinphraseCache[`${userId}-${roomId}`]?.phrase; + this.getRoom(room).send(phrase ?? ''); // Check if there's any relevant games const roomGames = Object.values(PSGames) @@ -22,7 +27,7 @@ export function joinHandler(this: Client, room: string, user: string, isIntro: b roomGames.forEach(game => { if (game.hasPlayerOrSpectator(user)) try { - game.update(toId(user)); + game.update(userId); } catch (err) { if (!(err instanceof ChatError)) throw err; } diff --git a/src/ps/loaders/index.ts b/src/ps/loaders/index.ts index d50a993f..00e1a897 100644 --- a/src/ps/loaders/index.ts +++ b/src/ps/loaders/index.ts @@ -2,6 +2,7 @@ import connection from '@/database'; import { IS_ENABLED } from '@/enabled'; import { loadAlts } from '@/ps/loaders/alts'; import { loadCommands } from '@/ps/loaders/commands'; +import { loadJoinphrases } from '@/ps/loaders/joinphrases'; import { loadRoomConfigs } from '@/ps/loaders/roomconfigs'; import { loadSeens } from '@/ps/loaders/seens'; @@ -12,6 +13,7 @@ export default async function init() { if (process.env.NODE_ENV !== 'development') { await loadAlts(); await loadSeens(); + await loadJoinphrases(); } await loadRoomConfigs(); } diff --git a/src/ps/loaders/joinphrases.ts b/src/ps/loaders/joinphrases.ts new file mode 100644 index 00000000..d540ed62 --- /dev/null +++ b/src/ps/loaders/joinphrases.ts @@ -0,0 +1,12 @@ +import { PSJoinphraseCache } from '@/cache'; +import { fetchAllJoinphrases } from '@/database/joinphrases'; +import { Logger } from '@/utils/logger'; + +export async function loadJoinphrases(): Promise { + const fetched = await fetchAllJoinphrases(); + fetched.forEach(entry => { + const { id, phrase } = entry; + PSJoinphraseCache[id] = { id, phrase }; + }); + Logger.log('Loaded Joinphrases!'); +} From cf7dd6b30e66aa13778f172d27319ad7b93a8734 Mon Sep 17 00:00:00 2001 From: TanPat Date: Thu, 4 Dec 2025 03:41:14 +0530 Subject: [PATCH 2/6] add minimum message and time conditions to send JP - add conditions of minimum time and messages since last JP per user per room - add otherHandler to track messages for miscellaneous use --- src/ps/handlers/joins.ts | 51 +++++++++++++++++++++++++++++++++++----- src/ps/index.ts | 1 + src/sentinel/live.ts | 3 ++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/ps/handlers/joins.ts b/src/ps/handlers/joins.ts index 70827584..13ab9724 100644 --- a/src/ps/handlers/joins.ts +++ b/src/ps/handlers/joins.ts @@ -6,18 +6,57 @@ import { debounce } from '@/utils/debounce'; import { fromHumanTime } from '@/utils/humanTime'; import { toId } from '@/utils/toId'; +import type { PSMessage } from '@/types/ps'; import type { Client } from 'ps-client'; +const minimumMessages: number = 5, + minimumTime: number = 30; // seconds + +interface jpState { + messageCount: number; // messages since last jp + lastTime: number; // timestamp of last jp +} +const jpStateMap: Partial> = {}; + +export function otherHandler(message: PSMessage) { + if (message.isIntro) return; + if (!message.author || !message.author.userid || !message.target || message.author.id === message.parent.status.userid) return; + if (message.content.startsWith('|')) return; + const roomId = message.target.id; + + for (const key in jpStateMap) { + if (!jpStateMap[key]) continue; + + const rId = key.split('-')[1]; + if (rId !== roomId) continue; + + jpStateMap[key]!.messageCount++; + } // increment message count for each joinphrase in the room +} + export function joinHandler(this: Client, room: string, user: string, isIntro: boolean): void { if (isIntro) return; - // Joinphrases - // 'Stalking' - // (Kinda creepy name for the feature, but it CAN be used in creepy ways so make sure it's regulated!) - // TODO Add minimum-messages-sent and minimum-time-since-last-occurence conditions + const userId = toId(user), roomId = toId(room); - const phrase = PSJoinphraseCache[`${userId}-${roomId}`]?.phrase; - this.getRoom(room).send(phrase ?? ''); + const key = `${userId}-${roomId}`; + const phrase = PSJoinphraseCache[key]?.phrase; + if (!phrase) return; + + let state = jpStateMap[key]; + if (!state) { + state = { messageCount: minimumMessages, lastTime: 0 }; + jpStateMap[key] = state; + } + const now = Date.now() / 1000; + const minimumMessagesReached: boolean = state.messageCount >= minimumMessages; + const minimumTimeReached: boolean = now - state.lastTime >= minimumTime; + if (!minimumTimeReached || !minimumMessagesReached) { + return; + } + this.getRoom(room).send(phrase); + state.messageCount = 0; + state.lastTime = now; // Check if there's any relevant games const roomGames = Object.values(PSGames) diff --git a/src/ps/index.ts b/src/ps/index.ts index cd8c2a63..52013489 100644 --- a/src/ps/index.ts +++ b/src/ps/index.ts @@ -16,6 +16,7 @@ if (IS_ENABLED.PS) loadPS().then(() => PS.connect()); PS.on('message', msg => registerEvent(PS, 'commandHandler')(msg)); PS.on('message', msg => registerEvent(PS, 'interfaceHandler')(msg)); PS.on('message', msg => registerEvent(PS, 'autoResHandler')(msg)); +PS.on('message', msg => registerEvent(PS, 'otherHandler')(msg)); PS.on('join', registerEvent(PS, 'joinHandler')); PS.on('joinRoom', registerEvent(PS, 'joinRoomHandler')); diff --git a/src/sentinel/live.ts b/src/sentinel/live.ts index e3cf86d6..e1aa4a19 100644 --- a/src/sentinel/live.ts +++ b/src/sentinel/live.ts @@ -8,7 +8,7 @@ import { permissions } from '@/ps/handlers/commands/permissions'; import { spoof } from '@/ps/handlers/commands/spoof'; import { interfaceHandler } from '@/ps/handlers/interface'; import { joinRoomHandler } from '@/ps/handlers/joinRooms'; -import { joinHandler, leaveHandler, nickHandler } from '@/ps/handlers/joins'; +import { joinHandler, leaveHandler, nickHandler, otherHandler } from '@/ps/handlers/joins'; import { notifyHandler } from '@/ps/handlers/notifications'; import { rawHandler } from '@/ps/handlers/raw'; import { tourHandler } from '@/ps/handlers/tours'; @@ -35,6 +35,7 @@ export const LivePSHandlers = { leaveHandler, nickHandler, notifyHandler, + otherHandler, rawHandler, tourHandler, }; From 9b10ad0b24625396d2c63a4bd9979890f9441a08 Mon Sep 17 00:00:00 2001 From: PartMan Date: Tue, 16 Dec 2025 02:20:08 +0530 Subject: [PATCH 3/6] refactor: Assorted bugs and comments --- src/cache/index.ts | 12 ++- src/database/joinphrases.ts | 32 +++---- src/ps/commands/joinphrases.ts | 103 -------------------- src/ps/commands/joinphrases.tsx | 165 ++++++++++++++++++++++++++++++++ src/ps/handlers/joins.ts | 55 +++-------- src/ps/handlers/other.ts | 26 +++++ src/ps/loaders/joinphrases.ts | 6 +- src/sentinel/live.ts | 3 +- 8 files changed, 234 insertions(+), 168 deletions(-) delete mode 100644 src/ps/commands/joinphrases.ts create mode 100644 src/ps/commands/joinphrases.tsx create mode 100644 src/ps/handlers/other.ts diff --git a/src/cache/index.ts b/src/cache/index.ts index 0d3a730b..b63c907f 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -21,7 +21,17 @@ export const PSCommands: { [key: string]: PSCommand & { path: string } } = {}; */ export const PSAliases: { [key: string]: string } = {}; export const PSAltCache: Partial<{ [key: string]: { from: string; to: string; at: Date } }> = {}; -export const PSJoinphraseCache: Partial<{ [key: string]: { id: string; phrase: string } }> = {}; +export const PSJoinphraseCache: Partial<{ + [room: string]: Partial<{ + [userId: string]: { + id: string; + phrase: string; + username: string; + messageCount: number; // messages since last JP + lastTime: number; // epoch timestamp of last JP + }; + }>; +}> = {}; export const PSSeenCache: Partial<{ [key: string]: { id: string; name: string; at: Date; seenIn: string[] } }> = {}; export const PSCronJobs: { manager: PSCronJobManager | null } = { manager: null }; diff --git a/src/database/joinphrases.ts b/src/database/joinphrases.ts index ff04b7e8..17a232de 100644 --- a/src/database/joinphrases.ts +++ b/src/database/joinphrases.ts @@ -1,6 +1,5 @@ import mongoose from 'mongoose'; -import { username } from '@/config/ps'; import { IS_ENABLED } from '@/enabled'; import { toId } from '@/utils/toId'; @@ -26,7 +25,7 @@ const schema = new mongoose.Schema({ }, userId: { type: String, - default: toId(username), + default: ({ username }: Model) => toId(username), }, roomId: { type: String, @@ -48,17 +47,20 @@ const schema = new mongoose.Schema({ const model = mongoose.model('joinphrase', schema, 'joinphrases', { overwriteModels: true }); -export async function addJoinphrase(username: string, roomId: string, phrase: string, by: string): Promise { +export async function setJoinphrase(username: string, roomId: string, phrase: string, by: string): Promise { if (!IS_ENABLED.DB) return null; const userId = toId(username); - return model.create({ - id: `${userId}-${roomId}`, - username, - userId, - roomId: roomId, - phrase, - addedBy: by, - }); + return model.findOneAndUpdate( + { + id: `${userId}-${roomId}`, + username, + userId, + roomId, + phrase, + addedBy: by, + }, + { upsert: true, new: true } + ); } export async function getJoinphrase(username: string, roomId: string): Promise<{ phrase: string } | null> { @@ -68,9 +70,9 @@ export async function getJoinphrase(username: string, roomId: string): Promise<{ return await model.findOne({ id }, { phrase: 1, _id: 0 }).lean(); } -export async function fetchAllJoinphrases(): Promise { +export async function fetchAllJoinphrases(roomId: string | null): Promise { if (!IS_ENABLED.DB) return []; - return model.find({}).lean(); + return model.find(roomId ? { roomId } : {}).lean(); } export async function deleteJoinphrase(username: string, roomId: string): Promise { @@ -79,9 +81,7 @@ export async function deleteJoinphrase(username: string, roomId: string): Promis const id = `${toId(username)}-${roomId}`; const toDelete = await model.findOne({ id }); - if (!toDelete) { - return null; - } + if (!toDelete) return null; await toDelete.deleteOne(); return toDelete.toObject(); } diff --git a/src/ps/commands/joinphrases.ts b/src/ps/commands/joinphrases.ts deleted file mode 100644 index 198bd03d..00000000 --- a/src/ps/commands/joinphrases.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { addJoinphrase, deleteJoinphrase, getJoinphrase } from '@/database/joinphrases'; -import { ChatError } from '@/utils/chatError'; - -import type { NoTranslate, ToTranslate } from '@/i18n/types'; -import type { PSCommand } from '@/types/chat'; - -export const command: PSCommand = { - name: 'jp', - help: 'Joinphrases module!', - perms: ['room', 'driver'], - syntax: 'CMD', - aliases: ['joinphrase'], - categories: ['utility'], - extendedAliases: { - addjp: ['jp', 'new'], - deletejp: ['jp', 'delete'], - removejp: ['jp', 'delete'], - remjp: ['jp', 'delete'], - getjp: ['jp', 'get'], - showjp: ['jp', 'get'], - displayjp: ['jp', 'get'], - }, - children: { - new: { - name: 'new', - help: 'Adds a new joinphrase for a given user', - perms: ['room', 'driver'], - flags: { allowPMs: false }, - syntax: 'CMD [User], [Joinphrase]', - aliases: ['add', 'a', 'n'], - async run({ message, arg, $T, checkPermissions }) { - if (!checkPermissions(['room', 'driver'])) { - throw new ChatError($T('ACCESS_DENIED')); - } - if (!arg) { - message.reply($T('INVALID_ARGUMENTS')); - } - const args: string[] = arg.split(',').map(s => s.trim()); - const username = args[0]; - const phrase = args[1]; - if (!phrase) { - message.reply('Put both username and message smh. Try again.' as ToTranslate); - return; - } - - try { - await addJoinphrase(username, message.target.id, phrase, message.author.name); - message.reply('Joinphrase Added!' as ToTranslate); - } catch (e: unknown) { - message.reply(`${username} already has a joinphrase in ${message.target.title}...` as ToTranslate); - } - }, - }, - delete: { - name: 'delete', - help: "Deletes a user's joinphrase", - perms: ['room', 'driver'], - syntax: 'CMD [User]', - aliases: ['del', 'remove', 'rem', 'd', 'r'], - async run({ message, arg, $T, checkPermissions }) { - if (!checkPermissions(['room', 'driver'])) { - throw new ChatError($T('ACCESS_DENIED')); - } - if (!arg) { - message.reply($T('INVALID_ARGUMENTS')); - return; - } - arg.trim(); - - if ((await deleteJoinphrase(arg, message.target.id)) === null) { - message.reply(`${arg} has no joinphrase in this room!` as ToTranslate); - } else { - message.reply('Joinphrase deleted.' as ToTranslate); - } - }, - }, - get: { - name: 'get', - help: 'Displays a given joinphrase', - perms: ['room', 'driver'], - syntax: 'CMD [User]', - aliases: ['show', 'display'], - async run({ message, arg, $T, checkPermissions }) { - if (!checkPermissions(['room', 'driver'])) { - throw new ChatError($T('ACCESS_DENIED')); - } - if (!arg) { - message.reply($T('INVALID_ARGUMENTS')); - return; - } - arg.trim(); - - const phraseObject = await getJoinphrase(arg, message.target.id); - const phrase = phraseObject?.phrase; - - message.privateReply(`${phrase}` as NoTranslate); - }, - }, - }, - async run({ arg, run }) { - return await run(`jp get ${arg}`); - }, -}; diff --git a/src/ps/commands/joinphrases.tsx b/src/ps/commands/joinphrases.tsx new file mode 100644 index 00000000..d91bbd65 --- /dev/null +++ b/src/ps/commands/joinphrases.tsx @@ -0,0 +1,165 @@ +import { toRoomID } from 'ps-client/tools'; + +import { deleteJoinphrase, fetchAllJoinphrases, getJoinphrase, setJoinphrase } from '@/database/joinphrases'; +import { MAX_MESSAGE_LENGTH } from '@/ps/constants'; +import { ChatError } from '@/utils/chatError'; +import { Username } from '@/utils/components'; + +import type { NoTranslate, PSMessageTranslated, ToTranslate } from '@/i18n/types'; +import type { PSCommand } from '@/types/chat'; + +function validateJoinphrase(phrase: string): void { + if (!phrase) throw new ChatError('A joinphrase cannot be empty!' as ToTranslate); + if (phrase.length > MAX_MESSAGE_LENGTH) + throw new ChatError(`A joinphrase cannot be longer than ${MAX_MESSAGE_LENGTH} characters!` as ToTranslate); + + // Security checks + if (phrase.startsWith('!') || phrase.startsWith('/')) { + const VALID_COMMANDS = ['!dt', '/me']; + if (!VALID_COMMANDS.some(cmd => phrase.startsWith(cmd + ' '))) { + throw new ChatError('A joinphrase cannot start with a command!' as ToTranslate); + } + } +} + +async function getRoom(message: PSMessageTranslated, arg: string): Promise { + if (message.type === 'chat') return message.target.roomid; + if (arg) return toRoomID(arg); + const reply = await message.target.waitFor(msg => msg.content.length > 0 && !!msg.parent.getRoom(toRoomID(msg.content))); + if (!reply) throw new ChatError('No room provided!' as ToTranslate); + return toRoomID(reply.content); +} + +export const command: PSCommand = { + name: 'joinphrase', + help: 'Joinphrases module! Joinphrases are messages that are sent when a user joins a room.', + perms: ['room', 'driver'], + syntax: 'CMD', + aliases: ['jp', 'joinphrases'], + categories: ['utility'], + extendedAliases: { + addjp: ['jp', 'new'], + addjoinphrase: ['jp', 'new'], + ajp: ['jp', 'new'], + deletejp: ['jp', 'delete'], + deletejoinphrase: ['jp', 'delete'], + djp: ['jp', 'delete'], + removejp: ['jp', 'delete'], + remjp: ['jp', 'delete'], + ejp: ['jp', 'edit'], + editjoinphrase: ['jp', 'edit'], + getjp: ['jp', 'get'], + showjp: ['jp', 'get'], + displayjp: ['jp', 'get'], + vjp: ['jp', 'get'], + viewjp: ['jp', 'get'], + }, + children: { + help: { + name: 'help', + help: 'Shows the help for the joinphrases command', + aliases: ['h'], + syntax: 'CMD', + async run({ run }) { + run('help jp'); + }, + }, + add: { + name: 'add', + help: 'Adds a new joinphrase for a given user', + flags: { allowPMs: false }, + syntax: 'CMD [user], [joinphrase]', + aliases: ['new', 'a', 'n'], + async run({ message, arg, $T }) { + if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); + const [username, phrase] = arg.lazySplit(/\s*,\s*/, 1).map(s => s.trim()); + if (!phrase) throw new ChatError($T('INVALID_ARGUMENTS')); + const targetUser = username.trim(); + if (await getJoinphrase(targetUser, message.target.id)) { + throw new ChatError(`${targetUser} already has a joinphrase in ${message.target.title}...` as ToTranslate); + } + validateJoinphrase(phrase); + await setJoinphrase(targetUser, message.target.id, phrase, message.author.name); + message.reply('Joinphrase Added!' as ToTranslate); + }, + }, + view: { + name: 'view', + help: 'Displays a given joinphrase', + syntax: 'CMD [user]', + flags: { allowPMs: false }, + aliases: ['show', 'display', 'get'], + async run({ message, arg, $T }) { + if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); + const targetUser = arg.trim(); + + const { phrase } = (await getJoinphrase(targetUser, message.target.id)) ?? {}; + if (!phrase) throw new ChatError(`${targetUser} does not have a joinphrase in ${message.target.title}...` as ToTranslate); + + message.privateReply(`${phrase}` as NoTranslate); + }, + }, + delete: { + name: 'delete', + help: "Deletes a user's joinphrase", + syntax: 'CMD [user]', + flags: { allowPMs: false }, + aliases: ['del', 'remove', 'rem', 'd', 'r'], + async run({ message, arg, $T }) { + if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); + const targetUser = arg.trim(); + + await deleteJoinphrase(targetUser, message.target.id); + message.reply('Joinphrase deleted.' as ToTranslate); + }, + }, + list: { + name: 'list', + help: 'Lists all joinphrases for a given user', + syntax: 'CMD [user]', + aliases: ['ls', 'l'], + async run({ message, arg }) { + const targetRoom = await getRoom(message, arg); + const joinphrases = await fetchAllJoinphrases(targetRoom); + + message.replyHTML( + + + {joinphrases.map(joinphrase => ( + + + + + ))} + +
+ + {joinphrase.phrase}
+ ); + }, + }, + edit: { + name: 'edit', + help: "Edits a user's joinphrase", + syntax: 'CMD [user], [joinphrase]', + flags: { allowPMs: false }, + aliases: ['e', 'update'], + async run({ message, arg, $T }) { + if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); + const [username, phrase] = arg.lazySplit(/\s*,\s*/, 1).map(s => s.trim()); + if (!phrase) throw new ChatError($T('INVALID_ARGUMENTS')); + const targetUser = username.trim(); + if (!(await getJoinphrase(targetUser, message.target.id))) { + throw new ChatError(`${targetUser} does not have a joinphrase in ${message.target.title}...` as ToTranslate); + } + validateJoinphrase(phrase); + await setJoinphrase(targetUser, message.target.id, phrase, message.author.name); + message.reply('Joinphrase edited.' as ToTranslate); + }, + }, + }, + async run({ run, arg }) { + if (arg) await run(`joinphrases view ${arg}`); + else await run(`help joinphrases`); + }, +}; diff --git a/src/ps/handlers/joins.ts b/src/ps/handlers/joins.ts index 13ab9724..591884d9 100644 --- a/src/ps/handlers/joins.ts +++ b/src/ps/handlers/joins.ts @@ -6,57 +6,24 @@ import { debounce } from '@/utils/debounce'; import { fromHumanTime } from '@/utils/humanTime'; import { toId } from '@/utils/toId'; -import type { PSMessage } from '@/types/ps'; import type { Client } from 'ps-client'; -const minimumMessages: number = 5, - minimumTime: number = 30; // seconds - -interface jpState { - messageCount: number; // messages since last jp - lastTime: number; // timestamp of last jp -} -const jpStateMap: Partial> = {}; - -export function otherHandler(message: PSMessage) { - if (message.isIntro) return; - if (!message.author || !message.author.userid || !message.target || message.author.id === message.parent.status.userid) return; - if (message.content.startsWith('|')) return; - const roomId = message.target.id; - - for (const key in jpStateMap) { - if (!jpStateMap[key]) continue; - - const rId = key.split('-')[1]; - if (rId !== roomId) continue; - - jpStateMap[key]!.messageCount++; - } // increment message count for each joinphrase in the room -} +const MIN_JP_MESSAGES = 20; // Number of messages required as a minimum gap +const MIN_JP_DELAY = fromHumanTime('30 seconds'); export function joinHandler(this: Client, room: string, user: string, isIntro: boolean): void { if (isIntro) return; - const userId = toId(user), - roomId = toId(room); - const key = `${userId}-${roomId}`; - const phrase = PSJoinphraseCache[key]?.phrase; - if (!phrase) return; - - let state = jpStateMap[key]; - if (!state) { - state = { messageCount: minimumMessages, lastTime: 0 }; - jpStateMap[key] = state; - } - const now = Date.now() / 1000; - const minimumMessagesReached: boolean = state.messageCount >= minimumMessages; - const minimumTimeReached: boolean = now - state.lastTime >= minimumTime; - if (!minimumTimeReached || !minimumMessagesReached) { - return; + const userId = toId(user); + const joinphraseData = PSJoinphraseCache[room]?.[userId]; + if (joinphraseData) { + const now = Date.now(); + if (now - joinphraseData.lastTime >= MIN_JP_DELAY && joinphraseData.messageCount >= MIN_JP_MESSAGES) { + this.getRoom(room).send(joinphraseData.phrase); + joinphraseData.messageCount = 0; + joinphraseData.lastTime = now; + } } - this.getRoom(room).send(phrase); - state.messageCount = 0; - state.lastTime = now; // Check if there's any relevant games const roomGames = Object.values(PSGames) diff --git a/src/ps/handlers/other.ts b/src/ps/handlers/other.ts new file mode 100644 index 00000000..ce17d303 --- /dev/null +++ b/src/ps/handlers/other.ts @@ -0,0 +1,26 @@ +import { PSJoinphraseCache } from '@/cache'; + +import type { PSMessage } from '@/types/ps'; + +export function otherHandler(message: PSMessage) { + if (message.isIntro) return; + if ( + !message.author || + !message.author.userid || + !message.target || + message.author.id === message.parent.status.userid || + message.type !== 'chat' + ) + return; + if (message.content.startsWith('|')) return; + + // Get the joinphrase data for this room + const roomJPData = PSJoinphraseCache[message.target.id]; + if (roomJPData) { + // Increment message count for each joinphrase in this room + for (const userId in roomJPData) { + const joinphraseData = roomJPData[userId]; + if (joinphraseData) joinphraseData.messageCount++; + } + } +} diff --git a/src/ps/loaders/joinphrases.ts b/src/ps/loaders/joinphrases.ts index d540ed62..4bda7753 100644 --- a/src/ps/loaders/joinphrases.ts +++ b/src/ps/loaders/joinphrases.ts @@ -3,10 +3,10 @@ import { fetchAllJoinphrases } from '@/database/joinphrases'; import { Logger } from '@/utils/logger'; export async function loadJoinphrases(): Promise { - const fetched = await fetchAllJoinphrases(); + const fetched = await fetchAllJoinphrases(null); fetched.forEach(entry => { - const { id, phrase } = entry; - PSJoinphraseCache[id] = { id, phrase }; + const { id, phrase, userId, username, roomId } = entry; + (PSJoinphraseCache[roomId] ??= {})[userId] = { id, phrase, username, messageCount: Infinity, lastTime: 0 }; }); Logger.log('Loaded Joinphrases!'); } diff --git a/src/sentinel/live.ts b/src/sentinel/live.ts index e1aa4a19..f0e0bd3e 100644 --- a/src/sentinel/live.ts +++ b/src/sentinel/live.ts @@ -8,8 +8,9 @@ import { permissions } from '@/ps/handlers/commands/permissions'; import { spoof } from '@/ps/handlers/commands/spoof'; import { interfaceHandler } from '@/ps/handlers/interface'; import { joinRoomHandler } from '@/ps/handlers/joinRooms'; -import { joinHandler, leaveHandler, nickHandler, otherHandler } from '@/ps/handlers/joins'; +import { joinHandler, leaveHandler, nickHandler } from '@/ps/handlers/joins'; import { notifyHandler } from '@/ps/handlers/notifications'; +import { otherHandler } from '@/ps/handlers/other'; import { rawHandler } from '@/ps/handlers/raw'; import { tourHandler } from '@/ps/handlers/tours'; From 7489367b9207c5cfc3c919462f5d545435f5b590 Mon Sep 17 00:00:00 2001 From: PartMan Date: Tue, 23 Dec 2025 01:54:11 +0530 Subject: [PATCH 4/6] chore: Fix JP aliases --- src/ps/commands/joinphrases.tsx | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ps/commands/joinphrases.tsx b/src/ps/commands/joinphrases.tsx index d91bbd65..b6f6445c 100644 --- a/src/ps/commands/joinphrases.tsx +++ b/src/ps/commands/joinphrases.tsx @@ -38,21 +38,21 @@ export const command: PSCommand = { aliases: ['jp', 'joinphrases'], categories: ['utility'], extendedAliases: { - addjp: ['jp', 'new'], - addjoinphrase: ['jp', 'new'], - ajp: ['jp', 'new'], - deletejp: ['jp', 'delete'], - deletejoinphrase: ['jp', 'delete'], - djp: ['jp', 'delete'], - removejp: ['jp', 'delete'], - remjp: ['jp', 'delete'], - ejp: ['jp', 'edit'], - editjoinphrase: ['jp', 'edit'], - getjp: ['jp', 'get'], - showjp: ['jp', 'get'], - displayjp: ['jp', 'get'], - vjp: ['jp', 'get'], - viewjp: ['jp', 'get'], + addjp: ['joinphrase', 'add'], + addjoinphrase: ['joinphrase', 'add'], + ajp: ['joinphrase', 'add'], + deletejp: ['joinphrase', 'delete'], + deletejoinphrase: ['joinphrase', 'delete'], + djp: ['joinphrase', 'delete'], + removejp: ['joinphrase', 'delete'], + remjp: ['joinphrase', 'delete'], + ejp: ['joinphrase', 'edit'], + editjoinphrase: ['joinphrase', 'edit'], + getjp: ['joinphrase', 'view'], + showjp: ['joinphrase', 'view'], + displayjp: ['joinphrase', 'view'], + vjp: ['joinphrase', 'view'], + viewjp: ['joinphrase', 'view'], }, children: { help: { From 08603bd35c21e39d0acef171546e46420b8375c2 Mon Sep 17 00:00:00 2001 From: PartMan Date: Tue, 23 Dec 2025 02:06:25 +0530 Subject: [PATCH 5/6] chore: Add JP feature checks --- src/database/psrooms.ts | 1 + src/ps/commands/joinphrases.tsx | 27 ++++++++++++++++----------- src/ps/handlers/commands/index.ts | 6 ++++++ src/types/chat.ts | 7 +++++++ src/types/ps.ts | 3 +++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/database/psrooms.ts b/src/database/psrooms.ts index ffeae092..95ed6239 100644 --- a/src/database/psrooms.ts +++ b/src/database/psrooms.ts @@ -25,6 +25,7 @@ const schema = new mongoose.Schema({ aliases: [String], private: Boolean, ignore: Boolean, + features: [String], permissions: Object, language: String, diff --git a/src/ps/commands/joinphrases.tsx b/src/ps/commands/joinphrases.tsx index b6f6445c..1886f4f2 100644 --- a/src/ps/commands/joinphrases.tsx +++ b/src/ps/commands/joinphrases.tsx @@ -32,7 +32,7 @@ async function getRoom(message: PSMessageTranslated, arg: string): Promise s.trim()); if (!phrase) throw new ChatError($T('INVALID_ARGUMENTS')); @@ -85,11 +86,12 @@ export const command: PSCommand = { }, view: { name: 'view', - help: 'Displays a given joinphrase', + help: 'Displays a given joinphrase.', syntax: 'CMD [user]', flags: { allowPMs: false }, aliases: ['show', 'display', 'get'], - async run({ message, arg, $T }) { + async run({ message, arg, $T, hasFeature }) { + if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate); if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); const targetUser = arg.trim(); @@ -101,11 +103,12 @@ export const command: PSCommand = { }, delete: { name: 'delete', - help: "Deletes a user's joinphrase", + help: "Deletes a user's joinphrase.", syntax: 'CMD [user]', flags: { allowPMs: false }, aliases: ['del', 'remove', 'rem', 'd', 'r'], - async run({ message, arg, $T }) { + async run({ message, arg, $T, hasFeature }) { + if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate); if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); const targetUser = arg.trim(); @@ -115,10 +118,11 @@ export const command: PSCommand = { }, list: { name: 'list', - help: 'Lists all joinphrases for a given user', + help: 'Lists all joinphrases for a given room.', syntax: 'CMD [user]', aliases: ['ls', 'l'], - async run({ message, arg }) { + async run({ message, arg, hasFeature }) { + if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate); const targetRoom = await getRoom(message, arg); const joinphrases = await fetchAllJoinphrases(targetRoom); @@ -144,7 +148,8 @@ export const command: PSCommand = { syntax: 'CMD [user], [joinphrase]', flags: { allowPMs: false }, aliases: ['e', 'update'], - async run({ message, arg, $T }) { + async run({ message, arg, $T, hasFeature }) { + if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate); if (!arg) throw new ChatError($T('INVALID_ARGUMENTS')); const [username, phrase] = arg.lazySplit(/\s*,\s*/, 1).map(s => s.trim()); if (!phrase) throw new ChatError($T('INVALID_ARGUMENTS')); diff --git a/src/ps/handlers/commands/index.ts b/src/ps/handlers/commands/index.ts index 45f3077a..973b3be0 100644 --- a/src/ps/handlers/commands/index.ts +++ b/src/ps/handlers/commands/index.ts @@ -115,6 +115,12 @@ export async function commandHandler(message: PSMessage, indirect: IndirectCtx | throw new ChatError(conceal ?? $T('PM_ONLY_COMMAND')); } + context.hasFeature = function (feature, room) { + if (message.type === 'pm') return null; + const roomConfig = PSRoomConfigs[room ?? message.target.id]; + return roomConfig?.features?.includes(feature) ?? false; + }; + context.checkPermissions = function (perm) { return usePermissions(perm, context.command, message); }; diff --git a/src/types/chat.ts b/src/types/chat.ts index 68864fce..aee9c83e 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -3,6 +3,7 @@ import type { PSMessageTranslated, TranslatedText, TranslationFn } from '@/i18n/types'; import type { DiscInteraction } from '@/types/discord'; import type { Perms } from '@/types/perms'; +import type { Feature } from '@/types/ps'; import type { SlashCommandBuilder } from 'discord.js'; import type { HTMLopts } from 'ps-client/classes/common'; import type { ReactElement } from 'react'; @@ -77,6 +78,12 @@ export type PSCommandContext = { * @param opts.perm The required permission to broadcast instead of privateReply. Defaults to 'voice' */ broadcastHTML(html: string | ReactElement, opts?: HTMLopts & { perm?: Perms }): void; + /** + * Checks whether the room has a given feature enabled. + * @param feature The feature to check for + * @returns true if the feature is enabled, false if it is not, or null for PMs. + */ + hasFeature(feature: Feature, room?: string): boolean | null; [key: string]: unknown; }; diff --git a/src/types/ps.ts b/src/types/ps.ts index 6eecb7f7..5f7ce67f 100644 --- a/src/types/ps.ts +++ b/src/types/ps.ts @@ -19,6 +19,8 @@ export type PSPointsType = { aliases?: string[]; }; +export type Feature = 'joinphrases' | string; + export type PSRoomConfig = { roomId: string; roomName?: string; @@ -31,6 +33,7 @@ export type PSRoomConfig = { aliases?: string[] | null; private?: true | null; ignore?: true | null; + features?: Feature[] | null; // You can put both commands (eg: `quote.add`) or group perms (eg: `games.create`) here. permissions?: { [key: string]: Perms; From ba0c7eecf39f1cb85050e8eb4f5b0ef46d2ae4e3 Mon Sep 17 00:00:00 2001 From: PartMan Date: Tue, 23 Dec 2025 02:45:40 +0530 Subject: [PATCH 6/6] chore: Fix JP database queries and cache sync --- src/database/joinphrases.ts | 20 +++++++++++++------- src/ps/commands/joinphrases.tsx | 8 ++++++-- src/ps/loaders/index.ts | 2 +- src/ps/loaders/joinphrases.ts | 8 +++++--- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/database/joinphrases.ts b/src/database/joinphrases.ts index 17a232de..c5b0162a 100644 --- a/src/database/joinphrases.ts +++ b/src/database/joinphrases.ts @@ -4,7 +4,8 @@ import { IS_ENABLED } from '@/enabled'; import { toId } from '@/utils/toId'; interface Model { - id: string; // * Unique field of form "userId-roomId" + /** userId-roomId */ + id: string; username: string; userId: string; roomId: string; @@ -25,7 +26,6 @@ const schema = new mongoose.Schema({ }, userId: { type: String, - default: ({ username }: Model) => toId(username), }, roomId: { type: String, @@ -53,11 +53,17 @@ export async function setJoinphrase(username: string, roomId: string, phrase: st return model.findOneAndUpdate( { id: `${userId}-${roomId}`, - username, - userId, - roomId, - phrase, - addedBy: by, + }, + { + $set: { + phrase, + addedBy: by, + }, + $setOnInsert: { + username, + userId, + roomId, + }, }, { upsert: true, new: true } ); diff --git a/src/ps/commands/joinphrases.tsx b/src/ps/commands/joinphrases.tsx index 1886f4f2..c887d2c5 100644 --- a/src/ps/commands/joinphrases.tsx +++ b/src/ps/commands/joinphrases.tsx @@ -2,6 +2,7 @@ import { toRoomID } from 'ps-client/tools'; import { deleteJoinphrase, fetchAllJoinphrases, getJoinphrase, setJoinphrase } from '@/database/joinphrases'; import { MAX_MESSAGE_LENGTH } from '@/ps/constants'; +import { loadJoinphrases } from '@/ps/loaders/joinphrases'; import { ChatError } from '@/utils/chatError'; import { Username } from '@/utils/components'; @@ -81,7 +82,8 @@ export const command: PSCommand = { } validateJoinphrase(phrase); await setJoinphrase(targetUser, message.target.id, phrase, message.author.name); - message.reply('Joinphrase Added!' as ToTranslate); + message.reply('Joinphrase added!' as ToTranslate); + loadJoinphrases(); }, }, view: { @@ -114,6 +116,7 @@ export const command: PSCommand = { await deleteJoinphrase(targetUser, message.target.id); message.reply('Joinphrase deleted.' as ToTranslate); + loadJoinphrases(); }, }, list: { @@ -144,7 +147,7 @@ export const command: PSCommand = { }, edit: { name: 'edit', - help: "Edits a user's joinphrase", + help: "Edits a user's joinphrase.", syntax: 'CMD [user], [joinphrase]', flags: { allowPMs: false }, aliases: ['e', 'update'], @@ -160,6 +163,7 @@ export const command: PSCommand = { validateJoinphrase(phrase); await setJoinphrase(targetUser, message.target.id, phrase, message.author.name); message.reply('Joinphrase edited.' as ToTranslate); + loadJoinphrases(); }, }, }, diff --git a/src/ps/loaders/index.ts b/src/ps/loaders/index.ts index 00e1a897..f3ce4fb0 100644 --- a/src/ps/loaders/index.ts +++ b/src/ps/loaders/index.ts @@ -13,8 +13,8 @@ export default async function init() { if (process.env.NODE_ENV !== 'development') { await loadAlts(); await loadSeens(); - await loadJoinphrases(); } + await loadJoinphrases(); await loadRoomConfigs(); } } diff --git a/src/ps/loaders/joinphrases.ts b/src/ps/loaders/joinphrases.ts index 4bda7753..89400c68 100644 --- a/src/ps/loaders/joinphrases.ts +++ b/src/ps/loaders/joinphrases.ts @@ -1,12 +1,14 @@ import { PSJoinphraseCache } from '@/cache'; import { fetchAllJoinphrases } from '@/database/joinphrases'; +import { emptyObject } from '@/utils/emptyObject'; import { Logger } from '@/utils/logger'; export async function loadJoinphrases(): Promise { + emptyObject(PSJoinphraseCache); const fetched = await fetchAllJoinphrases(null); - fetched.forEach(entry => { - const { id, phrase, userId, username, roomId } = entry; + + fetched.forEach(({ id, phrase, userId, username, roomId }) => { (PSJoinphraseCache[roomId] ??= {})[userId] = { id, phrase, username, messageCount: Infinity, lastTime: 0 }; }); - Logger.log('Loaded Joinphrases!'); + Logger.log('Loaded joinphrases!'); }