-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathbot.js
375 lines (347 loc) · 16.9 KB
/
bot.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
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
"use strict";
const Vocab = require('./vocab');
const History = require('./history');
const Score = require('./score');
const User = require('./user');
const Language = require('./language');
const TelegramBot = require('node-telegram-bot-api');
const Promise = require('bluebird');
const botan = require('botanio')(process.env.TELEGRAM_BOT_ANALYTICS_TOKEN);
const debug = require('debug')('bot');
const _ = require('lodash');
const useWebhook = Boolean(process.env.USE_WEBHOOK);
// TODO Differentiate between answers and commands. Use buttons for commands.
// Sometimes first message text is "/start Start" instead of just "/start": test with regexp
const startPattern = /^\/start/i;
const helpPattern = /^\/help$/i;
const statsPattern = /^\/stats?$/i;
const wordCountValuePattern = /^(\d+|десять|двадцать|пятьдесят|сто|ltcznm|ldflwfnm|gznmltczn|cnj) ?(слов|слова|слово|ckjd|ckjdf|ckjdj)?$/i;
const wordCountCommandPattern = /^\/count$/i;
const anotherWordPattern = /^слово$/i;
const skipPattern = /перевод|не знаю|дальше|не помню|^ещ(е|ё)|^\?$/i;
const yesPattern = /^да$|^lf$|^ага$|^fuf$|^ок$|^jr$|^ладно$|^хорошо$|^давай$|^yes$|^ok$/i;
const helpText = '«?» — показать перевод\n«слово» — новое слово\n/count — количество слов\n/stats — статистика';
const adminHelpText = '/vocab — показать слова для следующей недели';
const cyclePattern = /^\/cycle/i;
const nextVocabPattern = /^\/vocab/i;
const editWordPattern = /^\d{1,3}\.?\s?[a-zа-я]+/i;
/**
* Available states. State is a summary of user message recieved (e.g., a command, a wrong annswer or a next word request).
* @type {{next: string, stats: string, skip: string, correct: string, wrongOnce: string, wrongTwice: string, command: string}}
*/
const states = {
next: 'next',
stats: 'stats',
skip: 'skip',
correct: 'correct',
wrongOnce: 'wrongOnce',
wrongTwice: 'wrongTwice',
wrongThreeTimes: 'wrongThreeTimes',
wordCountCommand: 'wordCountCommand',
wordCountValue: 'wordCountValue',
helpCommand: 'helpCommand',
nextVocabCommand: 'nextVocabCommand',
unknown: 'unknown'
};
// Webhook for remote, polling for local
let options = useWebhook ? {
webHook: {
port: process.env.PORT || 5000
}
} : {polling: true};
let bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, options);
const main = function() {
if (useWebhook) {
setWebhook();
} else {
unsetWebhook();
}
// Listen for user messages
bot.on('message', function(userMessage) {
let chatId = userMessage.chat.id;
let userName = getUserName(userMessage);
// Handle cycle command (admin only). Handled separately because it does not need to reply to a sender chat id, but instead send messages to all admin users.
if (cyclePattern.test(userMessage.text) && User.isAdmin(chatId)) {
handleCycle()
.catch(function(error) {
// Previous cycle not finished: do nothing
if (error instanceof Vocab.PreviousCycleNotFinished) {
debug('previous cycle not finished');
return bot.sendMessage(chatId, 'С предыдущего обновления неделя ещё не прошла.');
// All other errors
} else {
console.log(error && error.stack);
}
});
// Handle all other messages
} else {
getBotMessage(userMessage)
.then(function(data) {
let options = _.extend({parse_mode: 'HTML'}, data.options);
let promises = [data];
if (data.message) {
promises.push(bot.sendMessage(chatId, data.message, options));
}
return Promise.all(promises);
})
.then(function(result) {
let data = result[0];
if (data) {
History.save(data, chatId);
}
let botMessage = result[1];
let botMessageTextLog = botMessage ? botMessage.text.replace(/\n/g, ' ') : null;
debug(`Chat ${chatId} ${userName}, tickets: ${botMessageTextLog}.`);
})
.catch(function(error) {
// No more words
if (error instanceof Vocab.NoTermsException) {
return bot.sendMessage(chatId, 'Слова закончились.');
// All other errors
} else {
console.log(error && error.stack);
}
});
}
});
};
/**
* Generates a reply
* @param {Object} userMessage
* @returns {Promise}
*/
const getBotMessage = function(userMessage) {
let chatId = userMessage.chat.id;
let userMessageText = _.trim(userMessage.text);
return History.get(chatId)
.then(function(data) {
let currentWord = data.word;
let translation = currentWord && currentWord.getTranslation();
return Promise.all([data, translation]);
})
.then(function(result) {
let data = result[0];
let currentWord = data.word;
let term = currentWord && currentWord.getTerm();
let translation = result[1];
// A summary of user message recieved prior to current user message.
let previousState = data.state;
let promise;
// Show next week vocabulary (admin only)
if (nextVocabPattern.test(userMessageText) && User.isAdmin(chatId)) {
promise = Vocab.getNextWords()
.then(function(words) {
let formatted = Vocab.formatWords(words);
let message = `Слова на следующую неделю:\n${formatted}`;
return {message: message, state: states.nextVocabCommand};
});
analytics(userMessage, '/vocab');
// Edit next week vocabulary (admin only)
} else if (editWordPattern.test(userMessageText) && User.isAdmin(chatId)) {
promise = Vocab.updateNextWords(userMessageText)
.then(function(editedWords) {
let formatted = Vocab.formatWords(editedWords);
return {message: editedWords.length ? `Ок, обновил:\n${formatted}` : '(Ничего не изменилось)'};
});
analytics(userMessage, 'edit vocab');
// Asking for help
} else if (helpPattern.test(userMessageText)) {
promise = {message: getHelpText(chatId), state: states.helpCommand};
analytics(userMessage, '/help');
// Starting the conversation or explicitly setting a word count
} else if (startPattern.test(userMessageText) || wordCountCommandPattern.test(userMessageText)) {
let message = `Сколько слов в неделю хочешь учить? 20 / 50 / ${Vocab.maxWordCount}?`;
let options = {
reply_markup: JSON.stringify({
keyboard: [
['10', '20', '50', '100']
],
resize_keyboard: true,
one_time_keyboard: true
})
};
promise = {message: message, options: options, state: states.wordCountCommand};
analytics(userMessage, startPattern.test(userMessageText) ? '/start' : '/count');
// Word count value
} else if (previousState === states.wordCountCommand && wordCountValuePattern.test(userMessageText)) {
let numberString = userMessageText.match(wordCountValuePattern)[1];
let number = Language.parseNumberString(numberString, Vocab.maxWordCount);
number = number > Vocab.maxWordCount ? Vocab.maxWordCount : number;
promise = User.setWordCount(number, chatId)
.then(function() {
return Promise.all([Vocab.createRandomWord(chatId), Score.count(chatId)]);
})
.then(function(result) {
let nextWord = result[0];
let scoreCount = result[1];
let formatted = formatWord(nextWord);
let numberCaption = Language.numberCaption(number, 'слово', 'слова', 'слов');
let newWordSentence = scoreCount === 0 ? `Первое слово:\n${formatted}` : `Следующее слово:\n${formatted}`;
let message = `Ок, ${number} ${numberCaption} в неделю.\n\n${newWordSentence}`;
return {word: nextWord, message: message, state: states.wordCountValue};
});
analytics(userMessage, 'set word count');
// Requesting stats
} else if (statsPattern.test(userMessageText)) {
promise = Score.getStats(chatId, Vocab.lifetime)
.then(function(data) {
let text = Score.formatStats(data.total);
let message = text ? `Статистика за неделю:\n${text}\n\nПоехали дальше?` : 'На этой неделе мы не учили слов.\nНачнём?';
return {message: message, state: states.stats};
});
analytics(userMessage, '/stats');
// Word requested: show random word.
} else if (anotherWordPattern.test(userMessageText) || yesPattern.test(userMessageText)) {
promise = Vocab.createRandomWord(chatId)
.then(function(word) {
return {word: word, message: formatWord(word), state: states.next};
});
analytics(userMessage, 'new word');
// Skipping the word
} else if (skipPattern.test(userMessageText)) {
// Wait for the score to save before proceeding
promise = Score.add(currentWord, Score.status.skipped, chatId)
.then(function() {
return Vocab.createRandomWord(chatId);
})
.then(function(nextWord) {
let message = '';
if (translation && term) {
message = `${translation} → ${term}\n\n`;
}
let formatted = formatWord(nextWord);
message += `Новое слово:\n${formatted}`;
return {word: nextWord, message: message, state: states.skip};
});
analytics(userMessage, 'skip');
// Answer is correct
} else if (isTermCorrect(term, userMessageText)) {
// Wait until the score is saved before choosing the next random word.
// Otherwise current word might be randomly chosen again, because it is not yet marked as correct.
promise = Score.add(currentWord, Score.status.correct, chatId)
.then(function() {
return Vocab.createRandomWord(chatId);
})
.then(function(nextWord) {
let formatted = formatWord(nextWord);
let message = `👍\n\nНовое слово:\n${formatted}`;
return {word: nextWord, message: message, state: states.correct};
});
analytics(userMessage, 'correct');
// Answer is wrong
} else if (currentWord) {
// Wait for the score to save before proceeding
promise = Score.add(currentWord, Score.status.wrong, chatId)
.then(function() {
let nextWord = true;
// If this is the third mistake, show correct answer and a new word
if (previousState === states.wrongTwice) {
nextWord = Vocab.createRandomWord(chatId);
}
return nextWord;
})
.then(function(nextWord) {
// TODO Handle the case when there is no current word / term
let message;
let state;
let word = currentWord;
// User has already been wrong twice, this is the third failed attempt
if (previousState === states.wrongTwice) {
let formatted = formatWord(nextWord);
message = `${translation} → ${term}\n\nНовое слово:\n${formatted}`;
state = states.wrongThreeTimes;
word = nextWord;
// Retry
} else {
let formatted = currentWord.getClue();
let lastAttempt = previousState === states.wrongOnce;
message = lastAttempt ? `Снова неправильно.\nПоследняя попытка:\n${formatted}` : `Нет, неправильно.\nСделай ещё одну попытку:\n${formatted}`;
state = lastAttempt ? states.wrongTwice : states.wrongOnce;
}
return {word: word, message: message, state: state};
});
analytics(userMessage, 'wrong');
} else {
// If bot encounters an unknown command two times in a row, show help
let secondTime = previousState === states.unknown;
promise = {state: states.unknown, message: secondTime ? getHelpText(chatId) : null};
analytics(userMessage, secondTime ? 'unclear, help shown' : 'unclear');
}
return promise;
});
};
/**
* Handles vocabulary cycle: replaces current words with next words and sends a new portion of vocabulary to all admins fo review
* @returns {Promise}
*/
const handleCycle = function() {
return Vocab.shouldStartCycle()
.then(function() {
return Vocab.cycle();
})
.then(function(nextWords) {
// Send message to admins with a list of next week words to review and correct within this week.
let formatted = Vocab.formatWords(nextWords);
let message = `Привет. Вот слова на следующую неделю. Исправь, если нужно:\n${formatted}`;
let promises = [];
// Sending words for review to all admin users
_.forEach(User.getAdminIds(), function(adminId) {
promises.push(bot.sendMessage(adminId, message, {parse_mode: 'HTML'}));
});
return Promise.all(promises);
})
.then(function() {
// Send stats message to all users
return manageStats();
});
};
/**
* Sends stats to all useers who have scores in given timespan.
*/
const manageStats = function() {
return Score.getAllStats(Vocab.lifetime)
.then(function(allStats) {
let promises = [];
_.forEach(allStats, function(data) {
let text = Score.formatStats(data.total);
if (text) {
promises.push(bot.sendMessage(data.chatId, `Статистика за неделю:\n${text}`, {parse_mode: 'HTML'}));
}
});
return Promise.all(promises);
})
.then(function() {
return Score.collapseStats(Vocab.lifetime);
});
};
/**
* Formats a word
* @param {Word} word
* @returns {Promise.<String>}
*/
const formatWord = function(word) {
let translation = word.getTranslation();
let clue = word.getClue();
return `${translation}\n${clue}`;
};
const isTermCorrect = function(term, userMessageText) {
return term && term.toLowerCase() === userMessageText.toLowerCase();
};
const getUserName = function(userMessage) {
return `${userMessage.chat.first_name || ''} ${userMessage.chat.last_name || ''}`;
};
const getHelpText = function(chatId) {
return helpText + (User.isAdmin(chatId) ? `\n${adminHelpText}` : '');
};
const analytics = function(userMessage, event) {
botan.track(userMessage, event);
};
const setWebhook = function() {
bot.setWebHook(`https://${process.env.APP_NAME}/?token=${process.env.TELEGRAM_BOT_TOKEN}`);
};
const unsetWebhook = function() {
bot.setWebHook();
};
module.exports = {
main: main
};