Skip to content

Commit ad1516f

Browse files
TannPatPartMan7
andauthored
feat: Add joinphrases module (#105)
* 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 * 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 * refactor: Assorted bugs and comments * chore: Fix JP aliases * chore: Add JP feature checks * chore: Fix JP database queries and cache sync --------- Co-authored-by: PartMan <parth.mane@sprinklr.com>
1 parent a072cae commit ad1516f

13 files changed

Lines changed: 356 additions & 5 deletions

File tree

src/cache/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ export const PSCommands: { [key: string]: PSCommand & { path: string } } = {};
2121
*/
2222
export const PSAliases: { [key: string]: string } = {};
2323
export const PSAltCache: Partial<{ [key: string]: { from: string; to: string; at: Date } }> = {};
24+
export const PSJoinphraseCache: Partial<{
25+
[room: string]: Partial<{
26+
[userId: string]: {
27+
id: string;
28+
phrase: string;
29+
username: string;
30+
messageCount: number; // messages since last JP
31+
lastTime: number; // epoch timestamp of last JP
32+
};
33+
}>;
34+
}> = {};
2435
export const PSSeenCache: Partial<{ [key: string]: { id: string; name: string; at: Date; seenIn: string[] } }> = {};
2536
export const PSCronJobs: { manager: PSCronJobManager | null } = { manager: null };
2637

src/database/joinphrases.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import mongoose from 'mongoose';
2+
3+
import { IS_ENABLED } from '@/enabled';
4+
import { toId } from '@/utils/toId';
5+
6+
interface Model {
7+
/** userId-roomId */
8+
id: string;
9+
username: string;
10+
userId: string;
11+
roomId: string;
12+
phrase: string;
13+
addedBy: string;
14+
at: Date;
15+
}
16+
17+
const schema = new mongoose.Schema<Model>({
18+
id: {
19+
type: String,
20+
required: true,
21+
unique: true,
22+
},
23+
username: {
24+
type: String,
25+
required: true,
26+
},
27+
userId: {
28+
type: String,
29+
},
30+
roomId: {
31+
type: String,
32+
required: true,
33+
},
34+
phrase: {
35+
type: String,
36+
default: '',
37+
},
38+
addedBy: {
39+
type: String,
40+
required: true,
41+
},
42+
at: {
43+
type: Date,
44+
default: Date.now,
45+
},
46+
});
47+
48+
const model = mongoose.model<Model>('joinphrase', schema, 'joinphrases', { overwriteModels: true });
49+
50+
export async function setJoinphrase(username: string, roomId: string, phrase: string, by: string): Promise<Model | null> {
51+
if (!IS_ENABLED.DB) return null;
52+
const userId = toId(username);
53+
return model.findOneAndUpdate(
54+
{
55+
id: `${userId}-${roomId}`,
56+
},
57+
{
58+
$set: {
59+
phrase,
60+
addedBy: by,
61+
},
62+
$setOnInsert: {
63+
username,
64+
userId,
65+
roomId,
66+
},
67+
},
68+
{ upsert: true, new: true }
69+
);
70+
}
71+
72+
export async function getJoinphrase(username: string, roomId: string): Promise<{ phrase: string } | null> {
73+
if (!IS_ENABLED.DB) return null;
74+
75+
const id = `${toId(username)}-${roomId}`;
76+
return await model.findOne({ id }, { phrase: 1, _id: 0 }).lean();
77+
}
78+
79+
export async function fetchAllJoinphrases(roomId: string | null): Promise<Model[]> {
80+
if (!IS_ENABLED.DB) return [];
81+
return model.find(roomId ? { roomId } : {}).lean();
82+
}
83+
84+
export async function deleteJoinphrase(username: string, roomId: string): Promise<Model | null> {
85+
if (!IS_ENABLED.DB) return null;
86+
87+
const id = `${toId(username)}-${roomId}`;
88+
const toDelete = await model.findOne({ id });
89+
90+
if (!toDelete) return null;
91+
await toDelete.deleteOne();
92+
return toDelete.toObject();
93+
}

src/database/psrooms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const schema = new mongoose.Schema<PSRoomConfig>({
2525
aliases: [String],
2626
private: Boolean,
2727
ignore: Boolean,
28+
features: [String],
2829
permissions: Object,
2930
language: String,
3031

src/ps/commands/joinphrases.tsx

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { toRoomID } from 'ps-client/tools';
2+
3+
import { deleteJoinphrase, fetchAllJoinphrases, getJoinphrase, setJoinphrase } from '@/database/joinphrases';
4+
import { MAX_MESSAGE_LENGTH } from '@/ps/constants';
5+
import { loadJoinphrases } from '@/ps/loaders/joinphrases';
6+
import { ChatError } from '@/utils/chatError';
7+
import { Username } from '@/utils/components';
8+
9+
import type { NoTranslate, PSMessageTranslated, ToTranslate } from '@/i18n/types';
10+
import type { PSCommand } from '@/types/chat';
11+
12+
function validateJoinphrase(phrase: string): void {
13+
if (!phrase) throw new ChatError('A joinphrase cannot be empty!' as ToTranslate);
14+
if (phrase.length > MAX_MESSAGE_LENGTH)
15+
throw new ChatError(`A joinphrase cannot be longer than ${MAX_MESSAGE_LENGTH} characters!` as ToTranslate);
16+
17+
// Security checks
18+
if (phrase.startsWith('!') || phrase.startsWith('/')) {
19+
const VALID_COMMANDS = ['!dt', '/me'];
20+
if (!VALID_COMMANDS.some(cmd => phrase.startsWith(cmd + ' '))) {
21+
throw new ChatError('A joinphrase cannot start with a command!' as ToTranslate);
22+
}
23+
}
24+
}
25+
26+
async function getRoom(message: PSMessageTranslated, arg: string): Promise<string> {
27+
if (message.type === 'chat') return message.target.roomid;
28+
if (arg) return toRoomID(arg);
29+
const reply = await message.target.waitFor(msg => msg.content.length > 0 && !!msg.parent.getRoom(toRoomID(msg.content)));
30+
if (!reply) throw new ChatError('No room provided!' as ToTranslate);
31+
return toRoomID(reply.content);
32+
}
33+
34+
export const command: PSCommand = {
35+
name: 'joinphrase',
36+
help: 'Joinphrases module! Joinphrases are messages that are sent when a user joins a room. The room must enable joinphrases to use these.',
37+
perms: ['room', 'driver'],
38+
syntax: 'CMD',
39+
aliases: ['jp', 'joinphrases'],
40+
categories: ['utility'],
41+
extendedAliases: {
42+
addjp: ['joinphrase', 'add'],
43+
addjoinphrase: ['joinphrase', 'add'],
44+
ajp: ['joinphrase', 'add'],
45+
deletejp: ['joinphrase', 'delete'],
46+
deletejoinphrase: ['joinphrase', 'delete'],
47+
djp: ['joinphrase', 'delete'],
48+
removejp: ['joinphrase', 'delete'],
49+
remjp: ['joinphrase', 'delete'],
50+
ejp: ['joinphrase', 'edit'],
51+
editjoinphrase: ['joinphrase', 'edit'],
52+
getjp: ['joinphrase', 'view'],
53+
showjp: ['joinphrase', 'view'],
54+
displayjp: ['joinphrase', 'view'],
55+
vjp: ['joinphrase', 'view'],
56+
viewjp: ['joinphrase', 'view'],
57+
},
58+
children: {
59+
help: {
60+
name: 'help',
61+
help: 'Shows the help for the joinphrases command.',
62+
aliases: ['h'],
63+
syntax: 'CMD',
64+
async run({ run }) {
65+
run('help jp');
66+
},
67+
},
68+
add: {
69+
name: 'add',
70+
help: 'Adds a new joinphrase for a given user.',
71+
flags: { allowPMs: false },
72+
syntax: 'CMD [user], [joinphrase]',
73+
aliases: ['new', 'a', 'n'],
74+
async run({ message, arg, $T, hasFeature }) {
75+
if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate);
76+
if (!arg) throw new ChatError($T('INVALID_ARGUMENTS'));
77+
const [username, phrase] = arg.lazySplit(/\s*,\s*/, 1).map(s => s.trim());
78+
if (!phrase) throw new ChatError($T('INVALID_ARGUMENTS'));
79+
const targetUser = username.trim();
80+
if (await getJoinphrase(targetUser, message.target.id)) {
81+
throw new ChatError(`${targetUser} already has a joinphrase in ${message.target.title}...` as ToTranslate);
82+
}
83+
validateJoinphrase(phrase);
84+
await setJoinphrase(targetUser, message.target.id, phrase, message.author.name);
85+
message.reply('Joinphrase added!' as ToTranslate);
86+
loadJoinphrases();
87+
},
88+
},
89+
view: {
90+
name: 'view',
91+
help: 'Displays a given joinphrase.',
92+
syntax: 'CMD [user]',
93+
flags: { allowPMs: false },
94+
aliases: ['show', 'display', 'get'],
95+
async run({ message, arg, $T, hasFeature }) {
96+
if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate);
97+
if (!arg) throw new ChatError($T('INVALID_ARGUMENTS'));
98+
const targetUser = arg.trim();
99+
100+
const { phrase } = (await getJoinphrase(targetUser, message.target.id)) ?? {};
101+
if (!phrase) throw new ChatError(`${targetUser} does not have a joinphrase in ${message.target.title}...` as ToTranslate);
102+
103+
message.privateReply(`${phrase}` as NoTranslate);
104+
},
105+
},
106+
delete: {
107+
name: 'delete',
108+
help: "Deletes a user's joinphrase.",
109+
syntax: 'CMD [user]',
110+
flags: { allowPMs: false },
111+
aliases: ['del', 'remove', 'rem', 'd', 'r'],
112+
async run({ message, arg, $T, hasFeature }) {
113+
if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate);
114+
if (!arg) throw new ChatError($T('INVALID_ARGUMENTS'));
115+
const targetUser = arg.trim();
116+
117+
await deleteJoinphrase(targetUser, message.target.id);
118+
message.reply('Joinphrase deleted.' as ToTranslate);
119+
loadJoinphrases();
120+
},
121+
},
122+
list: {
123+
name: 'list',
124+
help: 'Lists all joinphrases for a given room.',
125+
syntax: 'CMD [user]',
126+
aliases: ['ls', 'l'],
127+
async run({ message, arg, hasFeature }) {
128+
if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate);
129+
const targetRoom = await getRoom(message, arg);
130+
const joinphrases = await fetchAllJoinphrases(targetRoom);
131+
132+
message.replyHTML(
133+
<table>
134+
<tbody>
135+
{joinphrases.map(joinphrase => (
136+
<tr key={joinphrase.id}>
137+
<td>
138+
<Username name={joinphrase.username} clickable />
139+
</td>
140+
<td>{joinphrase.phrase}</td>
141+
</tr>
142+
))}
143+
</tbody>
144+
</table>
145+
);
146+
},
147+
},
148+
edit: {
149+
name: 'edit',
150+
help: "Edits a user's joinphrase.",
151+
syntax: 'CMD [user], [joinphrase]',
152+
flags: { allowPMs: false },
153+
aliases: ['e', 'update'],
154+
async run({ message, arg, $T, hasFeature }) {
155+
if (!hasFeature('joinphrases')) throw new ChatError('Joinphrases are not enabled for this room.' as ToTranslate);
156+
if (!arg) throw new ChatError($T('INVALID_ARGUMENTS'));
157+
const [username, phrase] = arg.lazySplit(/\s*,\s*/, 1).map(s => s.trim());
158+
if (!phrase) throw new ChatError($T('INVALID_ARGUMENTS'));
159+
const targetUser = username.trim();
160+
if (!(await getJoinphrase(targetUser, message.target.id))) {
161+
throw new ChatError(`${targetUser} does not have a joinphrase in ${message.target.title}...` as ToTranslate);
162+
}
163+
validateJoinphrase(phrase);
164+
await setJoinphrase(targetUser, message.target.id, phrase, message.author.name);
165+
message.reply('Joinphrase edited.' as ToTranslate);
166+
loadJoinphrases();
167+
},
168+
},
169+
},
170+
async run({ run, arg }) {
171+
if (arg) await run(`joinphrases view ${arg}`);
172+
else await run(`help joinphrases`);
173+
},
174+
};

src/ps/handlers/commands/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ export async function commandHandler(message: PSMessage, indirect: IndirectCtx |
115115
throw new ChatError(conceal ?? $T('PM_ONLY_COMMAND'));
116116
}
117117

118+
context.hasFeature = function (feature, room) {
119+
if (message.type === 'pm') return null;
120+
const roomConfig = PSRoomConfigs[room ?? message.target.id];
121+
return roomConfig?.features?.includes(feature) ?? false;
122+
};
123+
118124
context.checkPermissions = function (perm) {
119125
return usePermissions(perm, context.command, message);
120126
};

src/ps/handlers/joins.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PSAltCache, PSGames, PSSeenCache } from '@/cache';
1+
import { PSAltCache, PSGames, PSJoinphraseCache, PSSeenCache } from '@/cache';
22
import { rename } from '@/database/alts';
33
import { seeUser } from '@/database/seens';
44
import { ChatError } from '@/utils/chatError';
@@ -8,11 +8,22 @@ import { toId } from '@/utils/toId';
88

99
import type { Client } from 'ps-client';
1010

11+
const MIN_JP_MESSAGES = 20; // Number of messages required as a minimum gap
12+
const MIN_JP_DELAY = fromHumanTime('30 seconds');
13+
1114
export function joinHandler(this: Client, room: string, user: string, isIntro: boolean): void {
1215
if (isIntro) return;
13-
// Joinphrases
14-
// 'Stalking'
15-
// (Kinda creepy name for the feature, but it CAN be used in creepy ways so make sure it's regulated!)
16+
17+
const userId = toId(user);
18+
const joinphraseData = PSJoinphraseCache[room]?.[userId];
19+
if (joinphraseData) {
20+
const now = Date.now();
21+
if (now - joinphraseData.lastTime >= MIN_JP_DELAY && joinphraseData.messageCount >= MIN_JP_MESSAGES) {
22+
this.getRoom(room).send(joinphraseData.phrase);
23+
joinphraseData.messageCount = 0;
24+
joinphraseData.lastTime = now;
25+
}
26+
}
1627

1728
// Check if there's any relevant games
1829
const roomGames = Object.values(PSGames)
@@ -22,7 +33,7 @@ export function joinHandler(this: Client, room: string, user: string, isIntro: b
2233
roomGames.forEach(game => {
2334
if (game.hasPlayerOrSpectator(user))
2435
try {
25-
game.update(toId(user));
36+
game.update(userId);
2637
} catch (err) {
2738
if (!(err instanceof ChatError)) throw err;
2839
}

src/ps/handlers/other.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { PSJoinphraseCache } from '@/cache';
2+
3+
import type { PSMessage } from '@/types/ps';
4+
5+
export function otherHandler(message: PSMessage) {
6+
if (message.isIntro) return;
7+
if (
8+
!message.author ||
9+
!message.author.userid ||
10+
!message.target ||
11+
message.author.id === message.parent.status.userid ||
12+
message.type !== 'chat'
13+
)
14+
return;
15+
if (message.content.startsWith('|')) return;
16+
17+
// Get the joinphrase data for this room
18+
const roomJPData = PSJoinphraseCache[message.target.id];
19+
if (roomJPData) {
20+
// Increment message count for each joinphrase in this room
21+
for (const userId in roomJPData) {
22+
const joinphraseData = roomJPData[userId];
23+
if (joinphraseData) joinphraseData.messageCount++;
24+
}
25+
}
26+
}

src/ps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ if (IS_ENABLED.PS) loadPS().then(() => PS.connect());
1616
PS.on('message', msg => registerEvent(PS, 'commandHandler')(msg));
1717
PS.on('message', msg => registerEvent(PS, 'interfaceHandler')(msg));
1818
PS.on('message', msg => registerEvent(PS, 'autoResHandler')(msg));
19+
PS.on('message', msg => registerEvent(PS, 'otherHandler')(msg));
1920

2021
PS.on('join', registerEvent(PS, 'joinHandler'));
2122
PS.on('joinRoom', registerEvent(PS, 'joinRoomHandler'));

src/ps/loaders/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import connection from '@/database';
22
import { IS_ENABLED } from '@/enabled';
33
import { loadAlts } from '@/ps/loaders/alts';
44
import { loadCommands } from '@/ps/loaders/commands';
5+
import { loadJoinphrases } from '@/ps/loaders/joinphrases';
56
import { loadRoomConfigs } from '@/ps/loaders/roomconfigs';
67
import { loadSeens } from '@/ps/loaders/seens';
78

@@ -13,6 +14,7 @@ export default async function init() {
1314
await loadAlts();
1415
await loadSeens();
1516
}
17+
await loadJoinphrases();
1618
await loadRoomConfigs();
1719
}
1820
}

0 commit comments

Comments
 (0)