diff --git a/src/cache/index.ts b/src/cache/index.ts index b553c62..b63c907 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -21,6 +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<{ + [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 new file mode 100644 index 0000000..c5b0162 --- /dev/null +++ b/src/database/joinphrases.ts @@ -0,0 +1,93 @@ +import mongoose from 'mongoose'; + +import { IS_ENABLED } from '@/enabled'; +import { toId } from '@/utils/toId'; + +interface Model { + /** userId-roomId */ + id: string; + 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, + }, + 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 setJoinphrase(username: string, roomId: string, phrase: string, by: string): Promise { + if (!IS_ENABLED.DB) return null; + const userId = toId(username); + return model.findOneAndUpdate( + { + id: `${userId}-${roomId}`, + }, + { + $set: { + phrase, + addedBy: by, + }, + $setOnInsert: { + username, + userId, + roomId, + }, + }, + { upsert: true, new: true } + ); +} + +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(roomId: string | null): Promise { + if (!IS_ENABLED.DB) return []; + return model.find(roomId ? { roomId } : {}).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/database/psrooms.ts b/src/database/psrooms.ts index ffeae09..95ed623 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 new file mode 100644 index 0000000..c887d2c --- /dev/null +++ b/src/ps/commands/joinphrases.tsx @@ -0,0 +1,174 @@ +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'; + +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. The room must enable joinphrases to use these.', + perms: ['room', 'driver'], + syntax: 'CMD', + aliases: ['jp', 'joinphrases'], + categories: ['utility'], + extendedAliases: { + 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: { + 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, 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')); + 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); + loadJoinphrases(); + }, + }, + view: { + name: 'view', + help: 'Displays a given joinphrase.', + syntax: 'CMD [user]', + flags: { allowPMs: false }, + aliases: ['show', 'display', 'get'], + 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(); + + 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, 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(); + + await deleteJoinphrase(targetUser, message.target.id); + message.reply('Joinphrase deleted.' as ToTranslate); + loadJoinphrases(); + }, + }, + list: { + name: 'list', + help: 'Lists all joinphrases for a given room.', + syntax: 'CMD [user]', + aliases: ['ls', 'l'], + 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); + + 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, 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')); + 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); + loadJoinphrases(); + }, + }, + }, + async run({ run, arg }) { + if (arg) await run(`joinphrases view ${arg}`); + else await run(`help joinphrases`); + }, +}; diff --git a/src/ps/handlers/commands/index.ts b/src/ps/handlers/commands/index.ts index 45f3077..973b3be 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/ps/handlers/joins.ts b/src/ps/handlers/joins.ts index f270c23..591884d 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'; @@ -8,11 +8,22 @@ import { toId } from '@/utils/toId'; import type { Client } from 'ps-client'; +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; - // Joinphrases - // 'Stalking' - // (Kinda creepy name for the feature, but it CAN be used in creepy ways so make sure it's regulated!) + + 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; + } + } // Check if there's any relevant games const roomGames = Object.values(PSGames) @@ -22,7 +33,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/handlers/other.ts b/src/ps/handlers/other.ts new file mode 100644 index 0000000..ce17d30 --- /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/index.ts b/src/ps/index.ts index cd8c2a6..5201348 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/ps/loaders/index.ts b/src/ps/loaders/index.ts index d50a993..f3ce4fb 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'; @@ -13,6 +14,7 @@ export default async function init() { 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 0000000..89400c6 --- /dev/null +++ b/src/ps/loaders/joinphrases.ts @@ -0,0 +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(({ id, phrase, userId, username, roomId }) => { + (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 e3cf86d..f0e0bd3 100644 --- a/src/sentinel/live.ts +++ b/src/sentinel/live.ts @@ -10,6 +10,7 @@ import { interfaceHandler } from '@/ps/handlers/interface'; import { joinRoomHandler } from '@/ps/handlers/joinRooms'; 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'; @@ -35,6 +36,7 @@ export const LivePSHandlers = { leaveHandler, nickHandler, notifyHandler, + otherHandler, rawHandler, tourHandler, }; diff --git a/src/types/chat.ts b/src/types/chat.ts index 68864fc..aee9c83 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 6eecb7f..5f7ce67 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;