Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
93 changes: 93 additions & 0 deletions src/database/joinphrases.ts
Original file line number Diff line number Diff line change
@@ -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<Model>({
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<Model>('joinphrase', schema, 'joinphrases', { overwriteModels: true });

export async function setJoinphrase(username: string, roomId: string, phrase: string, by: string): Promise<Model | null> {
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<Model[]> {
if (!IS_ENABLED.DB) return [];
return model.find(roomId ? { roomId } : {}).lean();
}

export async function deleteJoinphrase(username: string, roomId: string): Promise<Model | null> {
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();
}
1 change: 1 addition & 0 deletions src/database/psrooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const schema = new mongoose.Schema<PSRoomConfig>({
aliases: [String],
private: Boolean,
ignore: Boolean,
features: [String],
permissions: Object,
language: String,

Expand Down
174 changes: 174 additions & 0 deletions src/ps/commands/joinphrases.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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(
<table>
<tbody>
{joinphrases.map(joinphrase => (
<tr key={joinphrase.id}>
<td>
<Username name={joinphrase.username} clickable />
</td>
<td>{joinphrase.phrase}</td>
</tr>
))}
</tbody>
</table>
);
},
},
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`);
},
};
6 changes: 6 additions & 0 deletions src/ps/handlers/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
21 changes: 16 additions & 5 deletions src/ps/handlers/joins.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)
Expand All @@ -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;
}
Expand Down
26 changes: 26 additions & 0 deletions src/ps/handlers/other.ts
Original file line number Diff line number Diff line change
@@ -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++;
}
}
}
1 change: 1 addition & 0 deletions src/ps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
2 changes: 2 additions & 0 deletions src/ps/loaders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,6 +14,7 @@ export default async function init() {
await loadAlts();
await loadSeens();
}
await loadJoinphrases();
await loadRoomConfigs();
}
}
Loading