Бот телеграм на nodejs

В общем-то бота для телеграм на PHP я уже писал, но в случае с PHP это выглядит довольно скучно. Есть, конечно, фреймворки и способы заставить PHP работать в «вечно» живущем цикле, ветвить задачи и т.д. но с т.з. асинхронного программирования все это выглядит какими-то дикими костылями. Так что для меня основным принципом PHP так и останутся «умирающие» скрипты. Не в том смысле, что они плохие, а в том что суть их жизни запуск-выполнение-результат-смерть. В качестве эксперимента, я писал воркер на PHP (по сути бесконечный цикл с таймаутом, который запускался supervisor. Собственно он забирал мессенджи из MySQL с флагом «не отправлено», слал их в xmpp и ставил флаг «отправлено». Вполне рабочий вариант, но как-то нет в нем никакого «очарования». Также было и с ботом.

Так вот давно у меня было желание переписать его на NodeJS. Желание было, а времени нет, да еще лень и отсутствие мотивации дают о себе знать. И вот, однажды (а именно две недели назад), просыпаюсь я от своей бессонницы с которой воюю уже полтора года блин. Ну как сказать воюю, она порой дарит мне кучу свободного времени, а порой мешает собрать мысли и разложить их в порядке иерархии. Так вот просыпаюсь и понимаю, что заняться с утра абсолютно нечем, соответственно необходимо придумать что-нибудь интересное. Вспоминаю что хотел написать бота для Telegram на NodeJS, а также что мы играем на работе командой в квиз штурм и было бы круто что-нибудь замутить. Ну и естественно начал парсить гугл на момент какой-нибудь апи или чего-то подобного, откуда можно грузануть в базу вопросов и ответов или готовой апи. Собственно и нашел уже готовую апишку. Дальше дело за малым.

Итак, нам нужен сервис, который будет

  1.  дергать эту самую API, получая пачки вопросов-ответов
  2.  выбирать случайный вопрос из пачки
  3.  подключится к api телеграма
  4. будет слушать диалоги и анализировать их на правильный ответ

Изначально решено было уйти от кластера и IPC, т.к. бот все равно будет стоять на виртуалке с одним ядром (собственно у меня есть штуки в датацентре и раздувать собственные расходы на такого рода развлечения я не горю желанием). Да, между прочим, на одной из них стоит этот блог, после того как меня достали тормоза хостингера — он переехал. Ну а заодно я решил отказаться от сайта (нет времени, да и к чему он вообще мне?) и блог переехал с сабдомена blog.sergdudko.tk на sergdudko.tk, но это заслуживает отдельной статьи, которую я конечно не напишу (нет, ну если только попросите). Так вот на одном ядре незачем плодить 100500 процессов, соответственно и кластер не нужен. А это все значит, что можно не «городить огород» межпроцессного взаимодействия. Сначала думал использовать firebase в качестве хранения данных, но потом решил что это тоже будет не нужная прослойка, так что воспользуюсь redux с копией хранилища в файле. Также думал использовать express и какую-нибудь библиотеку для работы с телеграмом, но от этого тоже решил отказаться. Все же я не вижу смысла использовать фреймворки на «мелких» проектах. А с библиотеками по работе с телеграмом пришлось бы еще и разбираться (зачем, если мне нужно всего пару функций, которые и сама api предоставляет).

Итак, согласно плану у нас есть редьюсер:

function editProcessStorage(state = {questions:[], question:{}, chats:{}}, action){
	try {
		switch (action.type){
			case 'SYNC_DB':
				var state_new = lodash.clone(state);
				for(const key in action.payload){
					state_new[key] = (lodash.clone(action.payload[key]));
				}
				return state_new;
				break;
			case 'ADD_QUESTIONS':	//добавление новых вопросов
				var state_new = lodash.clone(state);
				for(const key in action.payload){
					state_new.questions.push(lodash.clone(action.payload[key]));
				}
				return state_new;
				break;
			case 'ADD_ONE_QUESTION':
				var state_new = lodash.clone(state);
				if(state_new.questions.length > 0){
					const _id = parseInt(Math.random() * state_new.questions.length); 
					state_new.question = lodash.clone(state_new.questions[_id]);
					state_new.question['time'] = Date.now();
					var _t1 = state_new.question.answer.toLowerCase();
					const _t2 = _t1.indexOf('(');
					const _t3 = _t1.indexOf(')');
					if (_t2 !== -1){
						const _t4 = _t3 - _t2;
						if(_t4 > 0){
							_t1 = _t1.replace(_t1.substr(_t2,_t4),"");
						}
					}
					const _temp = _t1.match(/[\wа-яё]+/ig);
					state_new.question['md5'] = [];
					for(const key in _temp){
						state_new.question['md5'].push(cryptojs.Crypto.MD5(JSON.stringify(_temp[key])));
					}
					state_new.questions.splice(_id,1);
				}
				for(const chat in state_new.chats){
					if((Date.now() - 1800000) > state_new.chats[chat]){
						delete state_new.chats[chat];
					}
				}
				return state_new;
				break;
			case 'ADD_CHAT':
				var state_new = lodash.clone(state);
				state_new.chats[action.payload] = Date.now();
				return state_new;
				break;
			case 'ANSWERED_ONE_QUESTION':
				var state_new = lodash.clone(state);
				state_new.question = {};
				return state_new;
				break;
			default:
				break;
		}
	} catch(e){
		SendLogger("Ошибка при обновлении хранилища(мастер):" + e);
	}
	return state;
}

И две дополнительные функции, на предмет чтения базы данных из файла и записи в файл (которые я честно позаимствовал из другого своего проекта iocommander/client):

function getDatabase(){
	return new Promise(function (resolve){
		try {
			fs.readFile(__filename.replace(".js",".db"), "utf8", function(error,data){
				try {	
					if(error) {
						throw error;
					} else {
						resolve(JSON.parse(data));
					}
				} catch(e){
					SendLogger(datetime() + "База данных испорчена!");
					resolve('error');
				}
			});
		} catch (e) {
			SendLogger(datetime() + "База данных недоступна!");
			resolve('error');
		}
	});
}

function setDatabase(){
	try {
		var resultFs = fs.writeFileSync(__filename.replace(".js",".db"), JSON.stringify(ProcessStorage.getState()), (err) => {
			try{
				if (err) throw err;
			} catch(e){
				SendLogger("Проблема записи в базу данных!");
				setTimeout(setDatabase,15000); //при ошибке запустим саму себя через минуту
				return;
			}
		});
		if(typeof(resultFs) === 'undefined'){
			SyncDatabaseTimeout = false; //вернем начальное состояние флагу синхронизации
			console.log("Синхронизация с базой данных выполнена!");
			return;
		};
	} catch (e) {
		SendLogger("База данных недоступна!");
		setTimeout(setDatabase,15000); //при ошибке запустим саму себя через минуту
		return;
	}
}

А также функция чтения файла конфигурации (аналогична чтению файла базы):

function getSettings(){
	return new Promise(function (resolve, reject){
		try {
			fs.readFile(__filename.replace(".js",".conf"), "utf8", function(error,data){
				try {	
					if(error) {
						throw error; 
					} else {
						const _global = JSON.parse(data);
						for(const key in _global){
							global[key] = _global[key];
						}
						resolve('ok');
					}
				} catch(e){
					SendLogger('Ошибка чтения файла конфигурации' + e);
					resolve('error');
				}
			});
		} catch (e) {
			SendLogger('Ошибка чтения файла конфигурации' + e);
			resolve('error');
		}
	});
}

Из глобальных переменных у нас есть:

  • VERSION — версия приложения
  • global — объект, импортируемый из файла конфигурации
  • SyncDatabaseTimeout — флаг синхронизации redux с файлом
  • ProcessStorage — собственно хранилище redux
  • подключаемые библиотеки

Точка входа приложения:

getSettings().then(function(value){ if(value === 'ok'){
	var _question = ProcessStorage.getState().question.question;
	ProcessStorage.subscribe(function(){	//при обновлении хранилища - отправляем новые данные в воркеры
		if(!SyncDatabaseTimeout){ //проверяем что флаг ожидания синхронизации еще не установлен 
			SyncDatabaseTimeout = true; //установим флаг, что в хранилище есть данные ожидающие синхронизации
			setTimeout(setDatabase,15000); //синхронизируем хранилище через минуту (т.е. запрос не будет чаще, чем раз в минуту)
		}
		if(!(lodash.isEqual(_question, ProcessStorage.getState().question.question)) && (typeof(ProcessStorage.getState().question.question) !== 'undefined')){
			for(const chat in ProcessStorage.getState().chats){
				outgoingMsg(chat, 'Внимание, новый вопрос:\n'+ProcessStorage.getState().question.question);
			}
			_question = ProcessStorage.getState().question.question;
			console.log(ProcessStorage.getState().question);
		}
	});
	getDatabase().then(function(value){ 
		if(value !== 'error'){
			ProcessStorage.dispatch({type:'SYNC_DB', payload: value});
		}
		startMasterHandler();
	}).catch(function(error){
		SendLogger('' + error);
	});
}}).catch(function(error){
	SendLogger('' + error);
});

В точке входа, собственно мы инициализируем синхронизацию redux с файлом (при изменении). Также, при изменении актуального вопроса выводим его в лог и шлем в актуальные диалоги. При старте запускаем синхронизацию redux из файла, запускаем обработчик startMasterHandler.

Обработчик startMasterHandler запускает функцию получения стека вопросов GetQuestions и добавления нового вопроса AddQuestion при старте и каждые 10 секунд:

function GetQuestions(){
	if(ProcessStorage.getState().questions.length < 1000){
		RestRequest().then(function(val){
			ProcessStorage.dispatch({type:'ADD_QUESTIONS', payload: NormaliseObject(val)});
		}).catch(function(error){
			SendLogger('Ошибка в ожидании RestRequest:' + error);
		});
	}
}

function AddQuestion(){
	if(typeof(ProcessStorage.getState().question['time']) === 'undefined'){
		ProcessStorage.dispatch({type:'ADD_ONE_QUESTION'});
	} else if ((ProcessStorage.getState().question['time'] + 600000) < Date.now()) {
		for(const chat in ProcessStorage.getState().chats){
			outgoingMsg(chat, 'Время истекло, правильный ответ:\n'+ProcessStorage.getState().question.answer);
		}
		ProcessStorage.dispatch({type:'ADD_ONE_QUESTION'});
	}
}

А также веб-сервер startWebHookServer на заданном порту:

function startWebHookServer(){
	if((typeof(global['cert-crt']) !== 'undefined') && (global['cert-crt'] !== '') && (typeof(global['cert-key']) !== 'undefined') && (global['cert-key'] !== '') && (typeof(global['cert-ca']) !== 'undefined') && (global['cert-ca'] !== '')) {
		var ssl = {
			key: fs.readFileSync(global['cert-key']),
			cert: fs.readFileSync(global['cert-crt']) + '\n' + fs.readFileSync(global['cert-ca'])
		};
	} else{
		var ssl = 'error';
	}
	if(ssl !== 'error'){ //в зависимости от ssl запускаем http или https сервер
		var server = https.createServer(ssl, webserverfunc).listen(global['wh-port'], '0.0.0.0');
		SendLogger('https-сервер запущен на порту:' + global['wh-port']);
	} else {
		var server = http.createServer(webserverfunc).listen(global['wh-port'], '0.0.0.0');
		SendLogger('http-сервер запущен на порту:' + global['wh-port']);
	}
	server.timeout = 300000; //таймаут запроса
}

Веб сервер использует https при заданных сертификатах (вообще-то без ssl апи телеграма работать не будет, хотя бы самоподписаного. но для случая самоподписанных сертификатов я регистрацию не делал). Функция обработчик веб-сервера:

var webserverfunc = function(req, res){ 
	try {
		var req_url = url.parse(req.url);
		var params_url = new URLSearchParams(req_url.query);
		var logstring = req.connection.remoteAddress + " | " + req.method + " | " + req.url;
		console.log(logstring);  //пишем в лог запрос
		req.setEncoding('utf8'); //задаем принудительно utf-8
		switch(req.method){
			case 'POST':
				switch(req_url.pathname){
					case '/v1/wh-tg-bot':
						var rawData = '';
						req.on('data', (chunk) => { rawData += chunk; });	//получаем json из POST в rawData
						req.on('end', () => {
							try{
								res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
								res.end('OK');
								incommingMsg(JSON.parse(rawData.toString()));
							} catch(e){
								res.writeHead(400, {'Content-Type': 'text/plain; charset=utf-8'});
								res.end('Bad Request');
							}
						});
						break;
					case '/v1/registration':
						registrationBot(res);
						break;
					default:
						res.writeHead(404, {'Content-Type': 'text/plain; charset=utf-8'});
						res.end('Not Found');
						break;
				}
				break;
			default:
				res.writeHead(405, {'Content-Type': 'text/plain; charset=utf-8'});
				res.end('Method Not Allowed');
				break;
		}
	} catch(e){
		SendLogger('Ошибка обработки запроса: '+e)
		res.writeHead(500, {'Content-Type': 'text/plain; charset=utf-8'});
		res.end('Internal Server Error');
	}
}

Как видно основная работа бота будет по ссылке /v1/wh-tg-bot , а регистрация по ссылке /v1/registration. В обоих случая POST-запросом. Так что для регистрации бота нужно отправить POST на /v1/registration. 

Вот собственно сама функция регистрации:

var registrationBot = function(socket){
	var getoptions = url.parse('https://api.telegram.org/bot'+global['bot-key']+'/setWebhook');	//создаем параметры запроса 
	getoptions.method = 'POST';
	getoptions.headers = {};
	getoptions.headers["User-Agent"] = "CHGK-TG-BOT";
	getoptions.headers["Keep-Alive"] = "120";
	getoptions.headers["Accept-Charset"] = 'utf-8';
	getoptions.headers["Content-Type"] = 'application/json';
	var this_request = https.request(getoptions, (response) => {
		var _getoptions = url.parse('https://api.telegram.org/bot'+global['bot-key']+'/getWebhookInfo');	//создаем параметры запроса 
		_getoptions.method = 'POST';
		var _this_request = https.request(_getoptions, (_response) => {
			socket.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
			_response.pipe(socket);
		});
		_this_request.end(); 
	});
	this_request.write(JSON.stringify({'url':global['wh-url']+':'+global['wh-port']+'/v1/wh-tg-bot', "max_connections":100}));
	this_request.end();
}

Функция rest-запроса на базу ЧГК, вызываемая из GetQuestions:

function RestRequest(){
	return new Promise(function(resolve, reject){
		try{
			if (url.parse(global['chgk-url']).protocol === null) {	//определяем тип сервера и используемую библиотеку
				req = http;
			} else if (url.parse(global['chgk-url']).protocol === 'https:') {
				req = https;
			} else {
				req = http;
			}
			var getoptions = url.parse(global['chgk-url']);	//создаем параметры запроса 
			getoptions.method = 'GET';
			getoptions.headers = {};
			getoptions.headers["User-Agent"] = "CHGK-TG-BOT";
			getoptions.headers["Keep-Alive"] = "120";
			getoptions.headers["Accept-Charset"] = 'utf-8';
			getoptions.headers["Host"] = url.parse(global['chgk-url']).hostname;
			var this_request = req.request(getoptions, (response) => {
				var postdata = [];	//массив буфферов результата запроса
				const gunzipper = zlib.createGunzip();	//поток декомпрессии
				const Writable = stream.Writable();	//поток чтения
				Writable._write = function (chunk, enc, next) {	//обработка потока
					postdata.push(chunk);	//пушим буффер в массив
					next();
				};
				function closerErrStream(data){
					response.unpipe(Writable); //отвязываем потоки	
					response.destroy(); //уничтожаем потоки
					gunzipper.close();
					Writable.destroy();
					if(data){
						SendLogger('Ошибка обработки потоков: ' + data);
						resolve('error');
					}
				}
				gunzipper.on("error", function(err){ //обработка ошибок потоков
					closerErrStream(err);
				});
				Writable.on("error", function(err){ 
					closerErrStream(err);
				});
				response.on("error", function(err){ 
					closerErrStream(err);
				});
				Writable.on('finish', () => { 
					response.unpipe(); //отвязываем потоки
					response.destroy();	//уничтожаем потоки
					gunzipper.close();
					try{
						resolve(xml2js((Buffer.concat(postdata)).toString('utf8')));
					} catch(err){
						resolve('error');
					}
				});
				switch(response.statusCode){
					case 200:
						if(response.headers['content-encoding'] === 'gzip'){
							response.pipe(gunzipper).pipe(Writable);
						} else {
							response.pipe(Writable);
						}
						break; 
					default:
						resolve('error');
						SendLogger('ANSWER:' + response.statusCode + ' | ' + response.statusMessage);
						closerErrStream();
						break;
				}
			}); 
			if((typeof(dataClear) === 'string') && (dataClear !== '')){
				this_request.write(dataClear);	//отправка post-данных
			}
			this_request.on('error', function (e) {	//обработка ошибок
				SendLogger('Ошибка rest-запроса:'+e);
				resolve('error');
			});
			this_request.on('timeout', function () {	//обработка таймаута
				this_request.abort();
				SendLogger('Таймаут rest-запроса!');
				resolve('error');
			});
			this_request.setTimeout(60000);	//таймаут соединения
			this_request.end();
		} catch(err){
			SendLogger('Ошибка rest-запроса (глобальная обертка):'+err);
			resolve('error');
		}
	});
}

Функция нормальзации (приведения к нужному виду) полученных вопросов из базы ЧГК:

function NormaliseObject(data){
	const _data = lodash.clone(data);
	var _result = [];
	try {
		for(const key0 in _data.root){
			if(typeof(_data.root[key0]) === 'object'){
				for(const key1 in _data.root[key0]){
					if(_data.root[key0][key1].name === 'question'){
						var _tmp = {};
						for(const key2 in _data.root[key0][key1].children){
							if(_data.root[key0][key1].children[key2].name === 'Question'){
								_tmp[_data.root[key0][key1].children[key2].name.toLowerCase()] =  _data.root[key0][key1].children[key2].content.replace(/&lt;/gi,"<").replace(/&gt;/gi,">").replace(/&quot;/gi,"").replace(/&amp;#1118;/gi,"/").replace(/pic: /gi,"https://pda.baza-voprosov.ru/images/db/");
							}
							if(_data.root[key0][key1].children[key2].name === 'QuestionId'){
								_tmp[_data.root[key0][key1].children[key2].name.toLowerCase()] =  _data.root[key0][key1].children[key2].content.replace(/&quot;/gi,"");
							}
							if(_data.root[key0][key1].children[key2].name === 'Answer'){
								_tmp[_data.root[key0][key1].children[key2].name.toLowerCase()] =  _data.root[key0][key1].children[key2].content.replace(/&quot;/gi,"");
							}
						}
						if((typeof(_tmp['question']) === 'string') && (typeof(_tmp['questionid']) === 'string') && (typeof(_tmp['answer']) === 'string')){
							_result.push(_tmp);
						}
					}
				}
			}
		}
	} catch(err){
		SendLogger('Ошибка преобразования объекта:' + err);
	}
	return _result;
}

Функция отправки сообщений в телеграм:

function outgoingMsg(id, data, reply){
	var _silent = false;
	const _hour = (new Date).getHours();
	if((_hour < 8) || (_hour > 22)){
		_silent = true;
	}
	if(typeof(reply) === 'number'){
		var _msg = {chat_id:id, text:data, disable_notification:_silent, reply_to_message_id:reply};
	} else {
		var _msg = {chat_id:id, text:data, disable_notification:_silent};
	}
	var getoptions = url.parse('https://api.telegram.org/bot'+global['bot-key']+'/sendMessage');	//создаем параметры запроса 
	getoptions.method = 'POST';
	getoptions.headers = {};
	getoptions.headers["User-Agent"] = "CHGK-TG-BOT";
	getoptions.headers["Keep-Alive"] = "120";
	getoptions.headers["Accept-Charset"] = 'utf-8';
	getoptions.headers["Content-Type"] = 'application/json';
	var this_request = https.request(getoptions, (response) => {
		//console.log(''+response);
	});
	this_request.write(JSON.stringify(_msg));
	this_request.end();
}

Как видно, с 22 до 8 отправляет «тихие» сообщения. Если передан id сообщения, то отправленное сообщение будет ответом.

Обработка входящих сообщений в телеграм:

function incommingMsg(data){
	if(typeof(data.message) === 'object'){
		const _data = lodash.clone(data.message);
		ProcessStorage.dispatch({type:'ADD_CHAT', payload: _data.chat.id});
		var answer = '';
		if(((typeof(_data.from.first_name) === 'string') && (_data.from.first_name !== '')) || ((typeof(_data.from.last_name) === 'string') && (_data.from.last_name !== ''))){
			answer = _data.from.first_name + ' ' + _data.from.last_name + '(' + _data.from.id + ')';
		} else if ((typeof(_data.from.username) === 'string') && (_data.from.username !== '')){
			answer = _data.from.username + '(' + _data.from.id + ')';
		} else {
			answer = _data.from.id;
		}
		const text = _data.text.toLowerCase().match(/[\wа-яё\/]+/ig);
		switch(text[0]){
			case '/quest':
				outgoingMsg(_data.chat.id, ProcessStorage.getState().question.question);
				break;
			case '/next':
				for(const chat in ProcessStorage.getState().chats){
					outgoingMsg(chat, 'Вопрос сброшен пользователем '+answer+', правильный ответ:\n'+ProcessStorage.getState().question.answer);
				}
				ProcessStorage.dispatch({type:'ADD_ONE_QUESTION'});
				break;
			case '/start':
				outgoingMsg(_data.chat.id, 'Добро пожаловать в бота ЧГК. На повестке дня вопрос:\n'+ProcessStorage.getState().question.question);
				break;
			case '/chatid':
				outgoingMsg(_data.chat.id, _data.chat.id.toString());
				break;
			default:
				var rightanswer = false;
				var _temp = 0;
				for(const key in text){
					if(ProcessStorage.getState().question.md5.indexOf(cryptojs.Crypto.MD5(JSON.stringify(text[key]))) !== -1){
						_temp++;
					}
				}
				const _proc = _temp / ProcessStorage.getState().question.md5.length;
				if(_proc > 0.8){
					answer = answer + " - ответил верно!\nПравильный ответ:\n" + ProcessStorage.getState().question.answer;
					rightanswer = true;
				} else if(_temp !== 0) {
					answer = "Процент совпадения:"+parseInt(_proc*100);
				}
				if(rightanswer){
					for(const chat in ProcessStorage.getState().chats){
						outgoingMsg(chat, answer);
					}
					ProcessStorage.dispatch({type:'ANSWERED_ONE_QUESTION'});
				} else if(_temp !== 0) {
					outgoingMsg(_data.chat.id, answer, _data.message_id);
				}
				break;
		}
	} 
}

Как видно, есть 4 основных запроса:

  • /start — при инициализации бота
  • /quest — вернет текущий (актуальный) вопрос
  • /next — сбросит текущий вопрос
  • /chatid — вернет id чата (служебная)

Все остальные сообщения будут анализироваться на предмет правильного ответа.

Исходники и описание работы бота: https://github.com/namedudko/chgk-telegram-bot

Ну и я, как бы, не отрицаю что можно сделать и лучше. Бот написан «на коленке» за несколько часов, тем не менее вполне работает.

 

Ну и в качестве бонуса, установка:

  • скачиваю nodejs
wget https://nodejs.org/dist/v10.6.0/node-v10.6.0-linux-x64.tar.xz
  • устанавливаю его
sudo tar --strip-components 1 -xvf node-v* -C /usr/local
  • проверяю версии node и npm
node -v
npm -v
  • перехожу в папку /home
cd /home
  • клонирую репозиторий
git clone https://github.com/namedudko/chgk-telegram-bot.git
  • захожу в папку с репозиторием
cd /home/chgk-telegram-bot
  • устанавливаю nodejs модули
npm i cryptojs fs http https lodash nodemailer os redux stream url xml-parser zlib --save
  • пишу службу systemd, она представляет из себя файл типа *.service в папке
    /etc/systemd/

В нашем случае закину его в системные службы /etc/systemd/system/chgk.service

[Unit]
Description=CHGK-TG-BOT
After=network.target network-online.target remote-fs.target nss-lookup.target
Requires=network-online.target
Restart=on-failure
RestartSec=5

[Service]
User=root
Group=root
WorkingDirectory=/home/chgk-telegram-bot/
ExecStart=/bin/node /home/chgk-telegram-bot/chgk-tg-bot.js
ExecStop=/bin/kill -9 $(pidof /bin/node /home/chgk-telegram-bot/chgk-tg-bot.js)

[Install]
WantedBy=multi-user.target
  • перезагружаю демонов systemd
systemctl daemon-reload
  • запускаю демона
systemctl start chgk
  • проверяю статус
systemctl status chgk
  • при необходимости, добавляю демона в автозапуск
systemctl enable chgk
  • Журнал службы можно проверить командой
journalctl --unit=chgk --since=2018-08-10

где 2018-08-10 это 10 августа 2018г.