-
Notifications
You must be signed in to change notification settings - Fork 23
/
message_filter.js
301 lines (247 loc) · 11.8 KB
/
message_filter.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
// Copyright 2020 Las Venturas Playground. All rights reserved.
// Use of this source code is governed by the MIT license, a copy of which can
// be found in the LICENSE file.
// MySQL query for loading replacements from the database.
const LOAD_REPLACEMENTS_QUERY = `
SELECT
replacements.id,
replacements.user_id,
users.username,
replacements.replacement_before,
replacements.replacement_after
FROM
replacements
LEFT JOIN
users ON users.user_id = replacements.user_id`;
// MySQL query for storing replacement information in the database.
const STORE_REPLACEMENT_QUERY = `
INSERT INTO
replacements
(user_id, replacement_before, replacement_after)
VALUES
(?, ?, ?)`;
// MySQL query for removing a replacement query from the database, keyed by ID.
const REMOVE_REPLACEMENT_QUERY = `
DELETE FROM
replacements
WHERE
id = ?
LIMIT
1`;
// Maximum length of a message in main chat, in number of characters.
const kMaximumMessageLength = 122;
// Minimum message length before considering recapitalization.
const kRecapitalizeMinimumMessageLength = 10;
// Minimum uppercase-to-lowercase ratio before recapitalizing.
const kRecapitalizeMinimumCapitalRatio = .8;
// Common initialisms that we allow to be capitalized.
const kCommonInitialisms = new Set([
/\b(FYI)\b/ig, /\b(GG)\b/ig, /\b(LVP)\b/ig, /\b(WTF)\b/ig,
]);
// Escapes the |text| for safe usage in regular expressions.
function escape(text) {
return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
// The message filter is responsible for filtering the contents of messages. This could include
// censoring words, as well as replacing words with other words, or blocking messages in their
// entirety based on the severity of a word.
export class MessageFilter {
replacements_ = null;
constructor() {
this.replacements_ = new Set();
this.loadReplacementsFromDatabase();
}
// ---------------------------------------------------------------------------------------------
// Gets access to all the replacements that exist in the message filter.
get replacements() { return this.replacements_; };
// Adds the given replacement, changing |before| into |after|, as introduced by |player|. If
// |after| is the empty string, then the word will be blocked instead.
async addReplacement(player, before, after = '') {
const replacement = {
id: await this.addReplacementToDatabase(player, before, after),
userId: player.account.userId,
nickname: player.name,
before, after,
expression: new RegExp('(' + escape(before) + ')', 'gi'),
};
this.replacements_.add(replacement);
}
// Removes the replacement identified by the given |before|.
async removeReplacement(before) {
for (const replacement of this.replacements_) {
if (replacement.before !== before)
continue;
await this.removeReplacementFromDatabase(replacement.id);
this.replacements_.delete(replacement);
return;
}
}
// ---------------------------------------------------------------------------------------------
// Filters the given |message|, as sent by the |player|. If the filter decides to block this
// message, the |player| will be informed about this.
filter(player, message) {
// (1) Force-recapitalize the sentence if it's longer than a certain length, and the ratio
// between lower-case and upper-case characters exceeds a defined threshold.
if (message.length > kRecapitalizeMinimumMessageLength &&
this.determineCaseRatio(message) > kRecapitalizeMinimumCapitalRatio) {
message = this.recapitalize(message);
}
// (2) Apply each of the replacements to the |message|, to remove any bad words that may be
// included in it with alternatives. Replacements will maintain case.
for (const replacement of this.replacements_) {
if (!replacement.expression.test(message))
continue;
if (!replacement.after.length) {
player.sendMessage(Message.COMMUNICATION_FILTER_BLOCKED);
return null;
}
message = this.applyReplacement(message, replacement);
}
// (3) Cap the length of a message to a determined maximum, as messages otherwise would
// disappear into the void with no information given to the sending player at all.
const maximumLength = kMaximumMessageLength - player.name.length;
if (message.length > maximumLength)
message = this.trimMessage(message, maximumLength);
return message;
}
// Determines the ratio of upper-case characters in the full sentence. Punctuation signs,
// numbers and other non-A-Z characters will be ignored.
determineCaseRatio(message) {
let lowercaseCount = 0;
let uppercaseCount = 0;
for (let i = 0; i < message.length; ++i) {
const charCode = message.charCodeAt(i);
if (charCode >= 65 /* A */ && charCode <= 90 /* Z */)
++uppercaseCount;
else if (charCode >= 97 /* a */ && charCode <= 122 /* z */)
++lowercaseCount;
}
const totalCount = lowercaseCount + uppercaseCount;
return totalCount > 0 ? uppercaseCount / totalCount
: 0;
}
// Completely recapitalizes a message. Sentence case will be applied, a few common acronyms will
// be allowed to be capitalized (but only when in a stand-alone word), excess exclamation and
// question marks will be removed and player names will get proper capitalization.
recapitalize(message) {
let reformattedMessage = '';
// (1) Remove excess exclamation and question marks and punctuation in general.
message = message.replace(/([\?!`])\1+/g, '$1');
message = message.replace(/(([\?!`]){2})([\?!`])*/g, '$1');
message = message.replace(/[\.]{3,}/g, '...');
// (2) Recapitalize the beginning of sentences.
{
const sentences = message.replace(/([.?!])\s*(?=[A-Z])/g, '$1Ω').split('Ω');
for (let sentence of sentences) {
sentence = sentence[0].toUpperCase() + sentence.substring(1).toLowerCase();
sentence = sentence.replace(/\bi\b/ig, 'I');
// (3)) Uppercase common initialisms, as well as "I".
for (const initialism of kCommonInitialisms)
sentence = sentence.replace(initialism, (_, word) => word.toUpperCase());
reformattedMessage += sentence + ' ';
}
}
// (4) Recapitalize player names when they appear in full or as a prefix.
for (const player of server.playerManager) {
const nameExpression =
new RegExp('\\b' + escape(player.name), 'ig');
reformattedMessage = reformattedMessage.replace(nameExpression, player.name);
}
reformattedMessage = reformattedMessage.trimRight();
// (5) Make sure that their sentence ends with a punctuation mark.
if (!/[.?!]$/.test(reformattedMessage)) {
const firstWords = reformattedMessage.match(/(?:^|(?:[\.\?!]\s))(\w+)/g);
if (firstWords && firstWords.length > 0) {
const finalStart = firstWords.pop().toLowerCase();
for (const questionIndicator of ['what', 'why', 'how', 'is']) {
if (finalStart.endsWith(questionIndicator))
return reformattedMessage + '?';
}
}
reformattedMessage += '.';
}
return reformattedMessage;
}
// Applies the given |replacement| to the |message|. Case has to be maintained. The text can
// exist multiple times in the |message|, which all need to be replaced.
applyReplacement(message, replacement) {
return message.replace(replacement.expression, match => {
let casedReplacement = '';
let casedLength = Math.min(replacement.before.length, replacement.after.length);
for (let i = 0; i < casedLength; ++i) {
if (match.charCodeAt(i) >= 65 /* A */ && match.charCodeAt(i) <= 90 /* Z */)
casedReplacement += replacement.after[i].toUpperCase();
else
casedReplacement += replacement.after[i].toLowerCase();
}
return casedReplacement + replacement.after.substring(casedLength);
});
}
// Trims the given |message| to the given |maximumLength|. We'll find the closest word from
// that position and break there when it's close enough, otherwise apply a hard break.
trimMessage(message, maximumLength) {
const kCutoffText = '...';
// Determines exactly where the |message| should be cut.
const messageCutoffIndex = maximumLength - kCutoffText.length;
const messageCutoffWhitespace = message.lastIndexOf(' ', messageCutoffIndex);
// If the last whitespace character is within 8 characters of the message length limit, cut
// there. Otherwise cut the |message| exactly at the limit.
if (messageCutoffIndex - messageCutoffWhitespace <= 8)
return message.substring(0, messageCutoffWhitespace) + kCutoffText;
else
return message.substring(0, messageCutoffIndex) + kCutoffText;
}
// ---------------------------------------------------------------------------------------------
// Loads the replacements from the database, once a connection has been established. Mocked out
// for testing purposes, because we don't want tests accessing the database.
async loadReplacementsFromDatabase() {
let replacements = [
{
id: 1,
user_id: 116118,
username: 'Russell',
replacement_before: 'george',
replacement_after: 'geroge',
},
{
id: 2,
user_id: 116118,
username: 'Russell',
replacement_before: '/quit',
replacement_after: '',
}
];
if (!server.isTest()) {
const results = await server.database.query(LOAD_REPLACEMENTS_QUERY);
if (!results)
return; // an error occurred while running this query
replacements = results.rows;
}
// For each of the replacements, add them to the local cache.
for (const replacement of replacements) {
this.replacements_.add({
id: replacement.id,
userId: replacement.user_id,
nickname: replacement.username,
before: replacement.replacement_before,
after: replacement.replacement_after,
expression: new RegExp('(' + escape(replacement.replacement_before) + ')', 'gi'),
});
}
}
// Writes the replacement with the given information to the database, and returns the ID.
async addReplacementToDatabase(player, before, after) {
if (server.isTest())
return Math.floor(Math.random() * 100000);
const results = await server.database.query(
STORE_REPLACEMENT_QUERY, player.account.userId, before, after);
return results ? results.insertId
: null;
}
// Removes the replacement identified by the |replacementId| from the database.
async removeReplacementFromDatabase(replacementId) {
if (server.isTest())
return;
await server.database.query(REMOVE_REPLACEMENT_QUERY, replacementId);
}
}