-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbot.js
More file actions
430 lines (376 loc) · 18.5 KB
/
bot.js
File metadata and controls
430 lines (376 loc) · 18.5 KB
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
const { Attachment, ActivityHandler, MessageFactory, ActivityFactory, TurnContext, ConsoleTranscriptLogger } = require('botbuilder');
const { ContentModerator } = require('./services/content-moderator');
const { TextAnalytics } = require('./services/text-analytics');
const { UserManager } = require('./services/user-manager');
const { ChannelConversationManager } = require('./services/channel-conversation-manager');
const { locales } = require('./locales');
const { EntityBuilder } = require('./services/db/entity-builder');
const axios = require('axios');
const MAX_WARNINGS = 3;
class ModBot extends ActivityHandler {
constructor() {
super();
// Field for the moderation service
this.contentModerator = new ContentModerator();
// Field for the text analytics service
this.textAnalytics = new TextAnalytics();
// Field for the persistence
this.userManager = new UserManager();
this.userManager.init();
this.channelConversationManager = new ChannelConversationManager();
this.channelConversationManager.init();
// See https://aka.ms/about-bot-activity-message to learn more about the message and other activity types.
this.onMessage(async (context, next) => {
const receivedText = context.activity.text;
const attachments = context.activity.attachments;
// channelId from directLine or from supported channels
const channelId = context.activity.channelData.channelId || context.activity.channelId;
// channelId from directLine or from supported channels
const conversationId = context.activity.channelData.conversationId || (context.activity.conversation.id + "|" + context.activity.from.id);
let user = await this.userManager.find(channelId, context.activity.from.id);
if (!user) {
user = EntityBuilder.createUser(context.activity.from.id, channelId);
await this.userManager.add(user);
}
let channelConversation = await this.channelConversationManager.findById(user.channel, conversationId);
if (!channelConversation) {
// If is the first time that user commint an infraction in this channel, then store it
channelConversation = EntityBuilder.createChannelConversation(conversationId, context.activity.from.id, user.channel);
await this.channelConversationManager.addChannelConversation(channelConversation);
}
if (channelConversation.isBanned === true) {
// Direct line should manage the ban activity
if (context.activity.channelId !== "directline")
await this._deleteActivity(context);
}
else {
const language = await this._onTextReceived(context, receivedText, channelConversation);
// If the user sends a text message
if (language) {
const SECONDS_LIMIT = 3;
const conversationLength = channelConversation.last_messages.length;
if (conversationLength > 3) {
const lastDelay = channelConversation.last_messages[conversationLength - 1].timestamp -
channelConversation.last_messages[conversationLength - 2].timestamp;
const secondLastDelay = channelConversation.last_messages[conversationLength - 2].timestamp -
channelConversation.last_messages[conversationLength - 3].timestamp;
// Flooding detected
if (lastDelay < SECONDS_LIMIT && secondLastDelay < SECONDS_LIMIT) {
console.info("The user is sending messages to quickly.");
const isBanned = await this._warn(channelConversation);
let replyText = "";
if (isBanned === true) {
const user = context.activity.from.name || context.activity.from.id;
replyText = user + locales[language].ban_message;
} else {
replyText = locales[language].reply_flooding;
}
await context.sendActivity(MessageFactory.text(replyText));
}
}
}
await this._onAttachmentsReceived(context, attachments, language, channelConversation);
}
// By calling next() you ensure that the next BotHandler is run.
await next();
});
this.onEvent(async (context, next) => {
// Handle only messages with attachments on Direct Line
if (!context.activity.attachments) {
console.warn("[WARN]: This message should be not here", context.activity);
await next();
return;
}
// channelId from directLine or from supported channels
const channelId = context.activity.channelData.channelId || context.activity.channelId;
// channelId from directLine or from supported channels
const conversationId = context.activity.channelData.conversationId || (context.activity.conversation.id + "|" + context.activity.from.id);
let user = await this.userManager.find(channelId, context.activity.from.id);
if (!user) {
user = EntityBuilder.createUser(context.activity.from.id, channelId);
await this.userManager.add(user);
}
let channelConversation = await this.channelConversationManager.findById(user.channel, conversationId);
if (!channelConversation) {
// If is the first time that user commint an infraction in this channel, then store it
channelConversation = EntityBuilder.createChannelConversation(conversationId, context.activity.from.id, user.channel);
await this.channelConversationManager.addChannelConversation(channelConversation);
}
if (channelConversation.isBanned === true) {
// Direct line should manage the ban activity
if (context.activity.channelId !== "directline")
await this._deleteActivity(context);
}
else {
const attachments = context.activity.attachments;
await this._onAttachmentsReceived(context, attachments, "eng", channelConversation);
}
await next();
});
this.onMembersAdded(async (context, next) => {
const membersAdded = context.activity.membersAdded;
const user = context.activity.from.name || context.activity.from.id;
let index = Math.floor(Math.random() * Math.floor(locales["ita"].on_members_added_message.length));
const welcomeText = user + locales["ita"].on_members_added_message[index];
for (let cnt = 0; cnt < membersAdded.length; ++cnt) {
if (membersAdded[cnt].id !== context.activity.recipient.id) {
await context.sendActivity(MessageFactory.text(welcomeText, welcomeText));
}
}
// By calling next() you ensure that the next BotHandler is run.
await next();
});
};
/**
* Perform logic on text to detect bad words and personal infos
* @param {TurnContext} context
* @param {string} receivedText
* @param {ChannelConversation} channelConversation current conversation
* @typedef {{id, user, channel, number_of_warning, isBanned, bannedUntil, last_messages}} ChannelConversation
* @returns {string} Language spoken by the user in the bot
*/
async _onTextReceived(context, receivedText, channelConversation) {
if (!receivedText || receivedText.trim() === "")
return;
// Store the message sent for chat flooding detection
this.channelConversationManager.addMessage(channelConversation, context.activity.localTimestamp || new Date(), "text", context.activity.text);
const response = (await this.contentModerator.checkText(receivedText)).data;
let replyText = "";
const user = context.activity.from.name || context.activity.from.id;
// Azure Content Moderator service finds insults and forbidden language
if (response.Classification) {
if (response.Classification.ReviewRecommended)
replyText += `${locales[response.Language].reply_classification}`;
} else if (response.Terms) {
replyText += `${locales[response.Language].reply_dirty_words}`;
} else if (this._isScreaming(receivedText, 55) === true) {
// The received text is all uppercase
replyText = context.activity.channelId === "directline" ? `${locales[response.Language].reply_to_screaming}` : `${user}: ${locales[response.Language].reply_to_screaming}`;
await context.sendActivity(MessageFactory.text(replyText));
return response.Language;
}
const textAnalyticsResponse = (await this.textAnalytics.checkPII("1", receivedText)).data;
// Moderation on personal infos
// Message is not deleted automatically: the user can do it manually if he wants.
if (textAnalyticsResponse.documents[0].entities.length > 0) {
// Text Analytics service considers simple names as personal infos. We need to filter the response
const forbiddenEntities = textAnalyticsResponse.documents[0].entities.filter(entity => entity.category !== "Person");
if (forbiddenEntities.length > 0) {
replyText = context.activity.channelId === "directline" ? `${locales[response.Language].reply_personal_info}` : `${user}: ${locales[response.Language].reply_personal_info}`;
// No warnings for sharing personal infos
await context.sendActivity(MessageFactory.text(replyText));
return response.Language;
}
}
if (replyText != "") {
const isBanned = await this._warn(channelConversation);
if (isBanned === true) {
replyText = `${locales[response.Language].ban_message}`;
// Get conversation refence and store to db
const conversationReference = TurnContext.getConversationReference(context.activity);
this.channelConversationManager.addConversationReference(channelConversation, conversationReference);
}
if (context.activity.channelId !== "directline")
replyText = `${user}: ${replyText}`;
await context.sendActivity(MessageFactory.text(replyText));
if (isBanned === true)
switch (context.activity.channelId) {
case "directline":
await this._directLineBan(context);
break;
case "telegram":
await this._telegramBan(channelConversation);
break;
default:
break;
}
await this._deleteActivity(context);
}
return response.Language;
}
/**
* Perform logic on attachments (only images) to detect adult content
* @param {TurnContext} context
* @param {Attachment[]} attachments
* @param {string} language Language spoken by the user in the bot
* @param {*} channelConversation
*/
async _onAttachmentsReceived(context, attachments, language = "eng", channelConversation) {
if (!attachments)
return;
for (let i = 0; i < attachments.length; i++) {
const attachment = attachments[i];
if (!attachment.contentType.includes("image/"))
continue;
// Store the message sent for chat flooding detection
await this.channelConversationManager.addMessage(channelConversation, context.activity.localTimestamp || new Date(), "attachment", attachment.contentUrl);
const response = (await this.contentModerator.checkImage(attachment.contentUrl)).data;
if (response.IsImageAdultClassified || response.IsImageRacyClassified) {
let replyText;
const isBanned = await this._warn(channelConversation);
if (isBanned === true) {
const user = context.activity.from.name || context.activity.from.id;
replyText = user + locales[language].ban_message;
}
else
replyText = locales[language].reply_bad_image;
await context.sendActivity(MessageFactory.text(replyText));
if (isBanned === true)
switch (context.activity.channelId) {
case "directline":
await this._directLineBan(context);
break;
case "telegram":
await this._telegramBan(channelConversation);
break;
default:
break;
}
await this._deleteActivity(context);
}
}
}
/**
* Warn the channel conversation.
* If the number of warning is greater then MAX_WARNINGS the user'll be banned in this channel conversation
* @param {*} channelConversation
* @returns {boolean} true if the user is now banned, false otherwise
*/
async _warn(channelConversation) {
if (channelConversation.number_of_warning + 1 > MAX_WARNINGS) {
// Banned for one day
await this.channelConversationManager.ban(channelConversation);
return true;
}
else {
await this.channelConversationManager.warn(channelConversation);
return false;
}
}
/**
* Send a ban message on direct line
* @param {TurnContext} context
*/
async _directLineBan(context) {
const banEvent = ActivityFactory.fromObject({ activity_id: context.activity.id });
banEvent.type = 'custom.ban';
await context.sendActivity(banEvent);
}
/**
* Send a ban request on Telegram
* @param {*} channelConversation
*/
async _telegramBan(channelConversation) {
const options = {
baseUrl: "",
url: `${process.env.AzureFunctionURL}/api/ban/telegram/${channelConversation.id.split('|')[0]}/${channelConversation.user}`,
method: 'GET',
headers: {
'x-functions-key': process.env.BanFunctionKey,
}
};
try {
await axios.request(options);
}
catch (e) {
console.error(e);
}
}
/**
* @async Delete a message on Telegram
* @param {*} channelData
*/
async _deleteTelegramMessage(channelData) {
const options = {
baseUrl: "",
url: `${process.env.AzureFunctionURL}/api/deleteMsg/${channelData.message.chat.id}/${channelData.message.message_id}`,
method: 'GET',
headers: {
'x-functions-key': process.env.BanFunctionKey,
}
};
try {
await axios.request(options);
}
catch (e) {
console.error(e);
}
}
/**
* Manage the logic for unban users
* @param {TurnContext} context
* @param {*} channelConversation Channel conversation to unban
*/
async sendUnbanActivity(context, channelConversation) {
this.channelConversationManager.unban(channelConversation);
switch (channelConversation.channel) {
case "discord":
case "twitch":
// Direct line(s)
const unbanEvent = ActivityFactory.fromObject({ activity_id: context.activity.id });
unbanEvent.type = 'custom.unban';
const [guildId, userId] = channelConversation.id.split("|");
unbanEvent.channelData = { guildId, userId }
await context.sendActivity(unbanEvent);
break;
case "telegram":
const options = {
baseUrl: "",
url: `${process.env.AzureFunctionURL}/api/ban/${channelConversation.channel}/${channelConversation.id.split('|')[0]}/${channelConversation.user}`,
method: 'DELETE',
headers: {
'x-functions-key': process.env.UnbanFunctionKey,
}
};
try {
await axios.request(options);
}
catch (e) {
console.error(e);
}
break;
default:
// Emulator, web chat and others
break;
}
}
/**
* Delete the activity in context
* @param {TurnContext} context
*/
async _deleteActivity(context) {
const channel = context.activity.channelId;
try {
switch (channel) {
case "telegram":
this._deleteTelegramMessage(context.activity.channelData)
break;
default:
await context.deleteActivity(context.activity.id);
break;
}
} catch (e) {
// If the channel does not support deleteActivity, a custom event will be triggered
const deleteEvent = ActivityFactory.fromObject({ activity_id: context.activity.id });
deleteEvent.type = 'custom.delete'
await context.sendActivity(deleteEvent);
}
}
/**
* Checks if most of the sentence is capitalized. In a chat,to write all the text in uppercase
is equal to scream
* @param {string} text A string representing the sentence
* @param {number} threshold An integer value that represents the percentage of capital letters allowed
*/
_isScreaming(text, threshold = 55) {
const upperLength = text.replace(/[^A-Z]/g, '').length;
const percentage = (upperLength * 100) / text.replace(/\s/g, '').length;
if (percentage >= threshold) {
return true;
}
return false;
}
}
module.exports.ModBot = ModBot;