Статьи об IT

Пишу бота для телеграмм на php с GuzzleHttp и modx

И вначале реклама, небольшой блок. Прошу не сердиться и не вносить ее в фильтры блокировщиков.

подробнее о рекламодателе можно узнать внутри блока
Спасибо. А теперь сам материал.

Небольшой материал, как я пишу свой класс на языке php для работы универсального бота телеграм в связке с modx и GuzzleHttp.

Если вы опытный программист, то наверняка увидите кучу ошибок, отсутствие абстракции, неверного наследования и т.д. Если вы новичок, то возможно неправильно научитесь программированию и уж тем более, что для телеграма есть отличная готовая библиотека на языке php (например TelegramBotApi). Поэтому данную мою статью можно рассматривать исключительно как развлекательное чтиво, которое, иногда, стабильно работает. Вместо GuzzleHttp можно использовать Curl, но мне в Guzzle понравилось, что можно в исключениях записать в логи подробный ответ сервера телеграма (хотя у guzzle еще очень много плюсов). Изначально это все было мною написано на функциональном программировании и под modx 2 (но потому же принципу, хотя в принципе версия Modx роли не играет). Когда я стал переделывать по ООП, то решил заодно и написать эту заметку.

Я хотел реализовать следующее: вызывать класс и передавать в __construct полученный token бота, тем самым сделав его для единого url  универсальным, если ботов будет несколько.

Итак, предположим, что у вас есть этот самый токен и вы уже привязали домен к боту (регистрация бота осуществляется в телеграме при обращении к @BotFather с командой /newbot , далее задаете имя с словом bot в конце и получаем токен. Ну и да, можно сразу настроить имя, описание, аватарку и т.д. Обратите внимание, что токен по сути состоит из двух частей - уникальный id и ключ. А с помощью /setcommands можно создать менюшку с быстрыми командами для бота. Я буду миксовать кнопки и команды и их также создам уже непосредственно со своего класса). Привязка бота осуществляется путем связывания api telegram и вашего основного файла php, где будет происходить логика работы и обработка команд, с помощью /setWebhook.. опять же https://api.telegram.org/bot<token>/setWebhook?url=https://......../file.php

Наш основной и главной источник - официальная документация https://core.telegram.org/bots/api 

У меня будет два файла bot.php (который я типа привязал) и classbot.php . Оба файла я не разместил на github, ибо опозорюсь, еще раз, этот материал не рассматривайте как пошаговую инструкцию - это просто такой контент. Все это провожу и размещаю в домене, где установлен modx, а также мой компонент (хотя это опять же не важно)  и guzzle7. Замечу, что это не в рамках домента моего сайта spooky.ru

в файле classbot.php я пока подготовлю основные вещи. Вначале я, с помощью use, задаю использование пространства имен библиотеки GuzzleHttp, включая: RequestException - обработка исключений при запросах и Psr7 - использование одноименной библиотеки, а также MultipartStream - для потоковых запросов, чтобы можно было к ним обращаться. Подключил Modx, используя эту статью https://modx.com/blog/using-modx-outside-of-modx3 и проверил, что если глобальной переменной $modx нет, то назначаем (в принципе, возможно, это здесь не нужно, но я как-то делал себе класс, который подключался в снипете и оттуда по сути скопировал эту часть т.к. были ошибки без этого) и проверил существование класса Guzzle.Также у меня есть переменная $city - но это для моей задачи (там была еще одна переменная, которая бы вам по сути рассказала, что за бот :-) ). Внимание, можно было использовать и use для modx.

<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\MultipartStream;
use GuzzleHttp\Psr7\Request;
class TelegramBot {
protected $modx;
private $botToken;
private $chatId;
private $city;
public $message;
public $keyboard;
public function __construct($botToken, $city)
{
define('MODX_API_MODE', true);
require_once(dirname(__DIR__, 1) . '/config.core.php');
require_once MODX_CORE_PATH . 'model/modx/modx.class.php';
if(isset($GLOBALS['modx'])){
global $modx;
$this->modx = $modx;
} else {
$this->modx = new modX();
}
$this->modx->initialize('web');
if(!$this->modx->addPackage('MyComponen', MODX_CORE_PATH.'components/MyComponent/model/')){
$this->modx->log(1, 'error package name');
};
if (!class_exists('GuzzleHttp\Client')) {
$this->modx->log(1,"Внимание, необходим компонент GuzzleHttp7");
}
$this->botToken = $botToken;
$this->city = $city;
}
public function test() {
$this->modx->log(1, 'Ух ты, мой бот работает');
}
}

Сделал простой метод test, чтобы при его вызове в лог записалось сообщение для проверки работы всего этого

Чисто теоретически, при вызове new TelegramBot и передаче параметров у нас будет хранится бот токен для последующего использования. 

Теперь создам метод, который будет нам отдавать параметры тех, кто обратился к боту, ну то есть получать какие команды запросили пользователи (кстати, обязательные для использования /start/ help /about),  но вначале метод clearText, который на всякий случай будет очищать получаемые данные от лишнего всего

private function clearText($txt) {
$txt = trim(htmlentities(strip_tags($txt)));
$pattern = '/[^a-zA-Z0-9\/^,.-]+/';
$replacement = '';
$text = preg_replace($pattern, $replacement, $txt);
$text = addslashes($text);
return $text;
}

Стоит заметить, что у modx есть аналог stripTags, который заодно чистит символы специфические для платформы, но бот работает сугубо внутри телеграм и потому php функции будет достаточно

//TODO: обрабатываем полученное в бот сообщение. Создаем массив, который вернет данные
public function processIncomingMessage() {
$data = file_get_contents('php://input');
$data = json_decode($data, true);
$OutputParams = array('UserId' => null,'UserName' => null, 'UserFirstName' => null, 'UserLastName' => null, 'UserCmd' => null);
$OutputParams = array(
'UserId' => empty($data['message']['from']['id']) ? $this->clearText($data['callback_query']['from']['id']) : $this->clearText($data['message']['from']['id']),
'UserName' => empty($data['message']['from']['username']) ? $this->clearText($data['callback_query']['from']['username']) : $this->clearText($data['message']['from']['username']),
'UserFirstName' => empty($data['message']['from']['first_name'])? $this->clearText($data['callback_query']['from']['first_name']): $this->clearText($data['message']['from']['first_name']),
'UserLastName' => empty($data['message']['from']['last_name']) ? $this->clearText($data['callback_query']['from']['last_name']): $this->clearText($data['message']['from']['last_name']),
'UserCmd' => empty($this->clearText($data['message']['text'])) ? $this->clearText($data["callback_query"]["data"]) : $this->clearText($data['message']['text'])
);
return $OutputParams;
}

мы получаем ответ от телеграм (это по сути json) и заполняем  массив OutputParams . Нас интересует в первую очередь это UserCmd, а точнее команду, которую ввел пользователь. С помощью метода clearText я, еще раз, на всякий случай, очистил ее и теперь могу обработать в файле. Я всегда создаю комментарий TODO: для удобной навигации потом в IDE. И UserId - это по сути уникальный адрес пользователя, куда мы будем далее отправлять сообщения. Т.е. иными словами мы обработаем что отправлять и кому отправлять. 

в файле bot.php, который мы привязали ранее подключаем все необходимое и проверяем метод test (он должен в логи записать сообщение)

$bot_token = 'token:token';
require_once($_SERVER["DOCUMENT_ROOT"].'/classbot.class.php');
$bot = new TelegramBot($bot_token,'Belgorod');
$Params = $bot->processIncomingMessage();

Создаю экземпляр класса, куда передал токен и город и вызвал метод получения команды. Магическим образом, теперь при вводе команды в боте я это буду получать в переменную

$Params['UserCmd'];

Так обработаем их простым Switch Case в bot.php

switch($Params['UserCmd']) {
case '/test':
$bot->test();
break;
default:
$bot->test();
break;
}

При отправке команды /test выполнится соответствующий метод и в логи запишется мое сообщение. Такой себе интерактив, конечно, создам метод sendMessage , который будет отправлять сообщение обратно пользователю с текстом, в зависимости от полученной команды. 

Возвращаюсь в класс и создаю этот метод

//TODO: Отправка текста обратно пользователю
/**
*
*
* @param string $message что отправляем
* @param string $keyboard Кнопки
* @param string $chatId ID чата при необходимости
*/
public function sendMessage($message, $keyboard = '',$chatId='') {
$chat_id = '';
$url = "https://api.telegram.org/bot" . $this->botToken . "/sendMessage";
$chat_id = (empty($chatId)) ? $this->chatId : $chatId;
$data = [
'chat_id' => $chat_id,
'text' => $message,
'parse_mode' => 'HTML',
];
if ($keyboard !== null or !empty($keyboard)) {
$data['reply_markup'] = json_encode($keyboard, true);
}
if(!empty($chat_id)) {
try {
$client = new Client();
$response = $client->post($url, [
'form_params' => $data,
]);
if ($response->getStatusCode() == 200) {
return 'success';
} else {
$this->modx->log(1, $response->getStatusCode());
throw new Exception("HTTP Error !!!!!: " . $response->getStatusCode());
}

} catch (RequestException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '1 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '1 Request: ' . $err);
$this->modx->log(1, '1 Response: ' . $e->getResponse()->getBody()->getContents());
} catch (ClientException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '2 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '2 Request: ' . $err);
$this->modx->log(1, 'Response: ' . $e->getResponse()->getBody()->getContents());
} catch (Exception $e) {
$this->modx->log(1, '3 Exception caught: ' . $e->getMessage());
}
}
}

Рассказываю. Создал TODO и подсказки при вызове метода, что вводить в скобки, так называемые @params. Удобно при работе в IDE. Метод ожидает текст сообщения, клавиатуру (или кнопки) (если они есть) и уникальный ID чата пользователя (или просто ID пользователя). $url api telegram и метод sendMessage. Если chatId не задан явно, то возьмем его из вызванного (по сути по умолчанию так и будем работать). Моя логика была такая, если вдруг необходимо будет отравить кому-то какое-то конкретно сообщение от бота, то можно будет при вызове метода моего класса задать вручную кому. Keyboard позволит создать кнопки для бОльшего интерактива, в зависимости от логики вашего или моего бота работы и полученной команды. Дальше формируем данные для отправки массив $data, указываю там chat_id.  текст сообщения, нужный парсер (html) для форматирования текста. Дальше проверяю, если я отправляю кнопочки, то добавляю в массив еще и reply_markup в формате json. К этому еще вернусь при обработке команды. Это позволяет под сообщением создать сколько угодно разных кнопок с callback в виде команд. Например, это будут команды типа подробнее, или завтра, или информация и т.д. Ну и дальше создаю уже клиента Guzzle, который в создает post запрос к url выше, содержащий form_params в виде собранной информации $data. Если $response->getStatusCode() будет положителен, то сделаем return 'success', а если нет, то запишем в логи, что за код. Если же вообще наш post не сработает, то через catch (RequestException $e)  или catch (ClientException $e) запишем, что именно не понравилось серверу телеграма. Забегая вперед, я хочу оправдаться почему так много catch. У меня была лютая ошибка Bad Request: failed to send message with the error message "Wrong type of the web page content" и чтобы ее получить, пришлось нашпиговать столько исключений (хотя обычно используют try / catch один раз). Поэтому я понадобавлял 1, 2, 3 в сообщения в логи, потому что не мог понять как именно считать полученную ошибку. (К слову ошибка эта возникала при отправке изображений, где одно из изображений было удаленное и видимо в неверном формате. Решил путем кешеривания оной phtumb и отправки уже из кеша).  Собственно именно этот кусок кода меня отговариал от написания всего материала, т.к. в подсознании чувствую, что все это сплошной говнокод. Но уже пишу, а вам советую прокачать скилл и все переписать :-D 

Итак, теперь в коде ранее файла bot.php я могу использовать этот метод для отправки сообщения добавив в switch / case

switch($Params['UserCmd']) { .....

case '/about':
//TODO Команда about
$button1 = array('text' => "Пример кнопки 1", 'callback_data' => '/test');
$button2 = array('text' => "Пример кнопки 2", 'callback_data' => '/test');
$keyboard = array('inline_keyboard' => array(array($button1,$button2)));
$text_return = "Вы вызвали описание бота. Здесь должен находиться текст, который описывает, кто это и что это, и к кому обращаться в случае чего.";
$bot->sendMessage($text_return,$keyboard, $Params['UserID']);
break;
....
} ..... 

Здесь я также привожу пример вызова кнопок, как я и писал выше, под тексом сообщения они и появятся. С помощью array можно поработать над количество кнопок в одной строке, в моем примере будет все в одну строку. Но можно сделать и в две, добавив еще один array в перечисление главного. А callback_data, соответственно, при нажатии этих кнопок запустят эту команду. Все просто. и, собственно, в sendMessage я и отправляю текст, кнопки и ID пользователя, считанный ранее.

Это пока самая простая конструкция работы по следующей логике работы: создали уникальную конструкцию определенного бота, получили команду, обработали, отправили сообщение

Также я себе сделал еще следующие методы. Отправка аудио

//TODO: Отправка аудио
/**
*
*
* @param string $audioFile путь к аудио
* @param string $caption описание
*/
public function sendAudio($audioFile, $caption =null, $protect=null) {
$protect = (!empty($protect))? 'true' : 'false';
$url = "https://api.telegram.org/bot" . $this->botToken . "/sendAudio";
$file = fopen($audioFile, 'r');
$multipartStream = new MultipartStream([
['name' => 'chat_id', 'contents' => $this->chatId],
['name' => 'audio', 'contents' => $file],
['name' => 'caption', 'contents' => $caption],
['name' => 'protect_content', 'contents' => $protect]
]);
$request = new Request('POST', $url);
$request = $request->withBody($multipartStream);
try {
$client = new Client();
$response = $client->send($request);
if ($response->getStatusCode() == 200) {
return 'success';
} else {
$this->modx->log(1, $response->getStatusCode());
throw new Exception("HTTP Error: " . $response->getStatusCode());
}
} catch (RequestException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '1 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '1 Request: ' . $err);
$this->modx->log(1, '1 Response: ' . $e->getResponse()->getBody()->getContents());
} catch (ClientException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '2 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '2 Request: ' . $err);
$this->modx->log(1, 'Response: ' . $e->getResponse()->getBody()->getContents());
} catch (Exception $e) {
$this->modx->log(1, '3 Exception caught: ' . $e->getMessage());
}
}

 Вот здесь и используется MultipartStream, также я добавил возможность задавать protect - параметр, который запретит перессылку полученного сообщения

Отправка несколько картинок (самый спорный мой метод)

//TODO: Отправка изображений
/**
*
*
* @param string $urlPosters перечисление изображений с разделением символом |
* @param string @protect - защита от перессылки. 'true' или 'false'. по умолчанию false
*/
public function sendManyPicture($urlFile, $protect) {
$pArr = explode("|", $urlFile);
f (count($pArr) > 10) {
$pArr = array_slice($pArr, 0, (10 - count($pArr)));
} else {
array_pop($pArr);
}
$mediaItems = [];
foreach ($pArr as $item) {

if (!empty($item)) {
$fileThumb = $this->modx->runSnippet('pthumb', [
'input' => $item,
'options' => 'h=480'
]);
$mediaItems[] = [
'type' => 'photo',
'media' => $_SERVER['HTTP_HOST'].$fileThumb,
];
}
}
$protect = (!empty($protect))? $protect : 'false';
$url = "https://api.telegram.org/bot" . $this->botToken . "/sendMediaGroup";
try {
$client = new Client();
$response = $client->post($url, [
'multipart' => [
[
'name' => 'chat_id',
'contents' => $this->chatId,
],
[
'name' => 'protect_content',
'contents' => true,
],
[
'name' => 'media',
'contents' => json_encode(
$mediaItems
),
],
],
]);
} catch (RequestException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '1 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '1 Request: ' . $err);
$this->modx->log(1, '1 Response: ' . $e->getResponse()->getBody()->getContents());
} catch (ClientException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '2 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '2 Request: ' . $err);
$this->modx->log(1, 'Response: ' . $e->getResponse()->getBody()->getContents());
} catch (Exception $e) {
$this->modx->log(1, '3 Exception caught: ' . $e->getMessage());
}
}

Это один из моих самых тяжелых кодов, который тоже претендует на смех опытных програмистов. Что тут происходит, я вызываю метод, в который передают через разделитель массив картинок. Разбираю их и отсекаю лишние, если превысит 10 штук (лимит на одну отправку).  Далее перебираю весь массив и создаю, с помощью компонента modx pthumb, после чего их заново заношу в новым массив с уже кешированными файлами и с защитой от пересылки, после чего уже отправляю закомым способом. 

Отправка одного изображения

//TODO: Отправка фотографии пользователю
/**
*
*
* @param string $photoUrl адрес изображения
* @param string $message описание изображения
* @param string $protect защита от перессылки true или false
* @param string $keyboard Кнопки
*/
public function sendOnePicture($photoUrl, $message=null, $protect=null, $keyboard = null) {
$url = "https://api.telegram.org/bot" . $this->botToken . "/sendPhoto";
$data = [];
$protect = (!empty($protect))? 'true' : 'false';
if ($keyboard !== null) {
$data['name'] = 'reply_markup';
$data['contents'] = json_encode($keyboard, true);
}

$file = fopen($photoUrl, 'r');
$mainArray = [
['name' => 'chat_id', 'contents' => $this->chatId],
['name' => 'caption', 'contents' => $message],
['name' => 'name', 'contents' => 'photo'],
['name' => 'photo', 'contents' => $file],
['name' => 'filename', 'contents' => 'photo.jpg'],
['name' => 'protect_content', 'contents' => $protect]
];
$arrayData = array_merge(
$mainArray,
$data
);
$multipartStream = new MultipartStream($arrayData );
$request = new Request('POST', $url);
$request = $request->withBody($multipartStream);
try {
$client = new Client();
$response = $client->send($request);
if ($response->getStatusCode() == 200) {
return 'success';
} else {
$this->modx->log(1, $response->getStatusCode());
// throw new Exception("HTTP Error: " . $response->getStatusCode());
}
}
catch (RequestException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '1 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '1 Request: ' . $err);
$this->modx->log(1, '1 Response: ' . $e->getResponse()->getBody()->getContents());
} catch (ClientException $e) {
$err = Psr7\Message::toString($e->getRequest());
$this->modx->log(1, '2 Guzzle caught: ' . $e->getMessage());
$this->modx->log(1, '2 Request: ' . $err);
$this->modx->log(1, 'Response: ' . $e->getResponse()->getBody()->getContents());
} catch (Exception $e) {
$this->modx->log(1, '3 Exception caught: ' . $e->getMessage());
}
}

 Практически все тоже самое, что и для массива фотографий только задаю название для изображения.

В __construct я обращлся к modx и понятно, что не только из-за логов или вызова компонентов. У меня есть пару методов где применяется modx, например для выборки элементов из таблицы своего компонента, где я использую параметр $city, заданный ранее, и innerJoin, и GROUP_CONCAT(DISTINCT..., и т.д., но скорее всего в рамках этой статьи это лишнее. Хотя ладно, вот пример сложного запроса

$c = $this->modx->newQuery('Class');
$c->select(array(
'Class.id',
'Class.Name',
'Class.Picture',
'Class.city',
'Class2.city',
'Class2.id',
'Class2.date',
'Class2.active',
'GROUP_CONCAT(DISTINCT Class2.time ORDER BY Class2.time ASC SEPARATOR ", ")',"
)'
));
$c->innerJoin('Class2', 'Class2', 'Class.id = Class2.id');
$c->where(array(
'Class.city:='=> $this->city,
'AND:Class2.city:='=> $this->city,
'AND:Class2.date:=' => $day,
));
$c->groupby('Class2.name');
$c->sortby('Class2.time', 'ASC');
$c->prepare();
$c->stmt->execute();
$items = $c->stmt->fetchAll(PDO::FETCH_ASSOC);
$result = '';
foreach($items as $item) {
$result .= "\n" . $item['Name']; 
//......................
}
}
return $result;

Таким образом плодя и создавая последующие методы, я могу более лаконично их вызвать в первом файле, где обрабатываю полученные команды. 

А, и можно методом создать менюшку с командами, когда уже все опредилились

public function sendMyCommands($customCommands) {
$data = ['commands' => json_encode($customCommands),];
$Commands = [
['command' => '/start','description' => 'Начать работу с ботом'],
['command' => '/about','description' => 'О боте'],
['command' => '/help','description' => 'Справка']
];
$data['commands'] = json_encode(array_merge(json_decode($data['commands'], true), $Commands));
$url = "https://api.telegram.org/bot" . $this->botToken . "/setMyCommands";
$client = new Client();
$response = $client->post($url, [
'form_params' => $data,
]);
if ($response->getStatusCode() == 200) {
// return $response->getBody()->getContents();
return 'success';
} else {
$this->modx->log(1, $response->getStatusCode());
throw new Exception("HTTP Error: " . $response->getStatusCode());
}
}

В этом методе заранее определены базовые команды, но в файле  bot.php я могу их дополнить как захочу, например при команде /start

$arraycmd = [
['command' => '/cmd1','description' => 'Команда 1'],
['command' => '/cmd2','description' => 'Команда 2'],
];
$bot->sendMyCommands($arraycmd);

НО! в чем минусы всей этой статьи. Хотя при ее написании, мне казалось, что я пишу как некий отчет или курсовую. 

Во первых - есть готовые библиотеки
Во вторых - считается профиссиональным  это абстракции и подклассы. А я не считаю себя профессионалом
В третьих - использую modx я явно не правильно, за что Очень сильно извиняюсь перед сообществом 
В четвертых - где-то я перестарался, создав избыточность
В пятый, modx3 тоже умеет в пространство имен и мне надо было это использовать.

Но мне было и есть интересно и очень часто я обновляю и модифицирую свой код для более легкого восприятия. К слову бот, написанный здесь работает. Надеюсь вам было или интересно или весело читать. Приятного программирования. 

Не забывайте про резервные копии, гитхабы, официальные документации и так далее. если, все же, вам понравилось, в шапке мне можно задонатить, все же новый год скоро.

Похожее

draw I
draw I
draw I
draw I

 quote a81ca

Если вдруг вам было здесь полезно, уютно и приятно, что захотелось меня отблагодарить - вы можете пожертвовать мне на кофе.

 

i

Будет осуществлен переход на сайт Yoomoney

 

draw I

 


Внимание: На сайте могут присутствовать ссылки ePN

Мини-портфолио

очередной бесполезный блог