Зміст

WolfDispatcher

WolfDispatcher - це неймовірно простий, мінімалістичний та швидкий рівень абстракції над API Telegram який дозволить з неймовірною швидкістю розробляти ваших інтерактивних Telegram-ботів з будь-яким функціоналом, максимально не задумуючись над низькорівневими речима які вони робитимуть. Також максимальну увагу приділено стандартизації форматів даних з якими ви зможете працювати в процесі імплементації вашого бота та простоті деплою його на продакшн. За допомогою WolfDispatcher ви можете використовувати ваш модуль Ubilling, як платформу, що забезпечує функціонування бота, так і виконати його у вигляді переносимого on-premise рішення на базі фреймворку YALF. Також ви можете використовувати всього дві малесенькі бібліотеки, аби не тягнути за собою весь фреймворк та залишитись наодинці з вашим чудовим кодом та ідеями.

Як це працює?

Отак:

З чого почати?

У випадку, якщо у вас є досвід написання модулів для Ubilling у вас вже є експіренс роботи з фреймворком. Усі необхідні бібліотеки вже завантажено та ви можете пропустити підготовку середовища, перейшовши напряму до того аби фігачити код. Якщо ж ви не плануєте тягнути функціонал бота прямо в ваш біллінг, а хочете нормальне, окремо розташоване рішення, найпростішим та найзрозумілішим кейсом буде швиденько розвернути для цього YALF і вже на його базі зліпити цілісне та переносиме рішення.

Поїхали. Розгортаємо фреймворк, кудись в локацію доступну з вебу по https:

$ mkdir ourbot
$ cd ourbot
$ fetch http://yalf.nightfly.biz/yalf_current.tgz
$ tar zxvf yalf_current.tgz && rm -fr yalf_current.tgz
$ chmod -R 777 exports content config 

Вмикаємо потрібні для функціонування нашого боту “шари” бібліотек в config/yalf.ini. Мінімально, що нам буде потрібне наразі це бібліотеки wolfdispatcher, telegram та ubconfig, отож:

yalf.ini
LAYER_TELEBOT="telegram,wolfdispatcher,ubconfig"

Ось власне і все приготування оточення, котре буде необхідне нам для розробки. Переходимо власне до неї. Створюємо директорію, файлик модулю, та описовий файл модулю, де буде лежати власне обробник хуків нашого бота:

$ mkdir modules/general/ourbot
$ touch modules/general/ourbot/index.php
$ touch modules/general/ourbot/module.php

Та редагуємо їх відповідним чином

module.php
<?php
 
$this->registerModule($module, 'main', __('OurBot'), 'Author name');
index.php
<?php 
 
// Імплементація бота OurBot котра повинна наслідувати WolfDispatcher
class OurBot extends WolfDispatcher { }
 
// Створюємо інстанс бота з токеном бота Telegram
$bot = new OurBot('252624203:982384782349824232333');
// Вмикаємо автоматичну інсталляцю web-хуку для взаємодії з Telegram
$bot->hookAutosetup(true);
// Слухаємо що нам кажуть
$bot->listen();

Все, цих чотирьох рядків коду достатньо, задля того, щоб наш бот почав функціонувати і обробляти сповіщення про події, що відбулись в його полі зору. Насправді достатньо трьох рядків але про це трішки згодом. Отож після цього всього, заходимо за посиланням

https://наш_хост/директорія_бота/?module=ourbot

та бачимо наступне:

що означає, що хук вашого бота автоматично встановлено і Телеграм в майбутньому, буде відсилати сповіщення власне в цей URL. При бажанні автоматичне встановлення хука можна просто вимкнути методом hookAutosetup(false) або ж просто не викликаючи його на цьому інстансі взагалі. Автоматичне встановлення хука може бути корисним та зручним як при перенесенні вашого бота на інший сервер/URL так і при зміні його токена. Власне відбувається воно у випадку зміни будь якого з елементів пари URL+Bot token.

Окей, а як переконатись, що наш бот все “дуже уважно слухає”? Та дуже просто. Одразу після створення інстансу, переключаємо його в режим відладки.

$bot->setDebug(true);

Всі необхідні вам сповіщення почнуть сипатись в exports/botname_debug.log

$ tail -F exports/ourbot_debug.log

і як реакцію на щось таке

ми отримаємо такий результат

ourbot_debug.log
OurBot: 2022-08-12 16:49:36
Array
(
    [message_id] => 1295
    [from] => Array
        (
            [id] => 777777777
            [first_name] => Nightfly
            [username] => MyxaBkyco4kax
            [language_code] => uk
        )
 
    [chat] => Array
        (
            [id] => 777777777
            [type] => private
        )
 
    [date] => 1660312176
    [text] => привіт бот!
    [photo] => 
    [document] => 
    [voice] => 
    [audio] => 
    [video_note] => 
    [location] => 
    [sticker] => 
    [new_chat_member] => 
    [left_chat_member] => 
    [reply_to_message] => 
)
 
GT: 0.0038 QC: 0
 
Called actions: NONE
==================

Хеллоуворлди

Окей, вже результат. Але ж ми тут інтерактивності хочемо? А з чого там, з хеллоуворлдів в нормальних книжках починають? Ну давайте будемо реагувати на текстові привіти ботом якось. Заодно і coding-style свій трішки виправимо. Оскільки звичайно тримати в прямо в контроллері імплементацію всього бота так собі затія, і здоровою ідеєю було б винести її в окрему однойменну бібліотеку, та й хардкодити токени і всі інші опції прямо в контроллері “ну такоє собі”. Отож виносимо імплементацію нашого бота в автозавантажувану бібліотеку api/libs/api.ourbot.php

api.ourbot.php
<?php
 
class OurBot extends WolfDispatcher {
 
    protected function actionHello() {
        $this->replyTo('Привіт');
    }
 
}

запихуємо всі конфігурабельні штуки нашого бота в config/alter.ini

alter.ini
OURBOT_TOKEN="252624203:982384782349824232333"
OURBOT_DEBUG=1
OURBOT_AUTOSETUP=1

а контроллер приводимо до якогось такого вигляду

index.php
<?php
 
$bot = new OurBot($ubillingConfig->getAlterParam('OURBOT_TOKEN'));
$bot->setDebug($ubillingConfig->getAlterParam('OURBOT_DEBUG'));
$bot->hookAutosetup($ubillingConfig->getAlterParam('OURBOT_AUTOSETUP'));
 
// Це масив екшонів. Формат рядок=>дія. 
// Дією може бути як метод класу імплементації боту (як приватний так і публічний) так і ім`я просто функції.
// У випадку, якщо це функція першим і єдиним параметром їй буде передано повний масив отриманого повідомлення $this->receivedData
$commands = array(
    'привіт' => 'actionHello'
);
 
// Передаємо набір екшенів в діспатчер. Він сам розбереться, що з тим робити далі.
$bot->setActions($commands);
 
// Слухаємо
$bot->listen();

отримуючи повністю закономірний результат

власне, якщо ми зазирнемо в дебаг-лог нашого бота ми побачимо там щось схоже на оце:

ourbot_debug.log
..........
    [date] => 1660313508
    [text] => привіт
    [photo] => 
    [document] => 
    [voice] => 
    [audio] => 
    [video_note] => 
    [location] => 
    [sticker] => 
    [new_chat_member] => 
    [left_chat_member] => 
    [reply_to_message] => 
)
 
GT: 0.0164 QC: 0
 
Called actions: Array
(
    [0] => METHOD: actionHello
)
 
==================

Що власне свідчить, що на основі того, що в $this→receivedData (він же $this→message()) діспатчером було знайдено підрядок “привіт” задекларований в нашій структурі екшнів, було викликано відповідний йому метод $this→actionHello(). Власне екшн міг би бути і просто PHP-функцією а не методом. Тоді його було б викликано ось так: actionHello($this→receivedData). А в лозі ми б побачили сповіщення такого типу:

Called actions: Array
(
    [0] => FUNC: actionHello
)

у випадку, якщо відповідно екшну не знайдено ні методу ні функції в рамках виконуваного коду, при ввімкненому дебаг режимі ми отримаємо прямо собі в чат отаке сповіщення

що в лозі буде виглядати приблизно так

Called actions: Array
(
    [0] => FAILED: actionHello2
)

Також є пачка “магічних методів”, які за замовчуванням “роблять нічого” але все ж виконуються в різних ситуаціях. Їх ви теж можете задефайнити в вашій імплементації та навісити на них потрібний вам функціонал. Ось вони:

 // виконується у випадку, якщо для повідомлення не знайдено відповідний екшн/обробник
 $this->handleEmptyAction();
 
 // виконується у випадку якщо надійшло повідомлення з порожнім текстом
 $this->handleEmptyText();
 
 // виконується у випадку якщо отримано фотокартку
 $this->handlePhotoReceived();
 
 // виконується як реакція на будь-яке повідомлення взагалі завжди
 $this->handleAnyWay();

усі ці методи виконуються у випадку, якщо користувач, від якого надійшло повідомлення є “дозволеним для інтеракції”, тобто або фігурує в структурі allowedChatiIds (типу ACL-ка) або ж не фігурує в структурі ignoredChatIds (читай бан-лист) які заповнюються відповідними їм сеттерами setAllowedChatIds() та setIgnoredChatIds(). Винятком є тільки обробник handleAnyWay() котрий відпрацьовує “взагалі завжди” і “взагалі для всіх”.

Давайте розширимо функціонал нашого хеллоуворлдного боту чимось осмисленішим, по дорозі ненав'язливо продемонструвавши як редефайнити магічні хендлери та реагувати на різне. І будемо з цим всім закінчувати, переходячи до чогось осмисленого.

api.ourbot.php
class OurBot extends WolfDispatcher {
 
    const CHATS_LOG_PATH = 'exports/allchats.log';
 
    /**
     * Just reacts for user greeting personally
     * 
     * @return void
     */
    protected function actionHelloName() {
        $hisName = $this->receivedData['from']['first_name'];
        $this->reply(__('Hello') . ' ' . $hisName);
    }
 
    /**
     * Replies on photo posted in chat
     * 
     * @return void
     */
    protected function actionPhotoWow() {
        if ($this->isPhotoReceived()) {
            $message = $this->message(); //returns $this->receivedData copy
            if ($message['text']) {
                $replyText = 'Ого яка фотка! Це точно ' . $message['text'] . '?';
            } else {
                $replyText = 'Нічогенька така картинка';
            }
            $this->replyTo($replyText);
        }
    }
 
    /**
     * Just logs all chats non empty text messages to some log file
     * 
     * @return void
     */
    protected function handleAnyWay() {
        if (!empty($this->receivedData['text'])) {
            $logData = date("Y-m-d H:i:s", $this->receivedData['date']) . ' ' . $this->chatId . ' ';
            $logData .= $this->receivedData['from']['first_name'] . ' ' . $this->receivedData['text'] . PHP_EOL;
            file_put_contents(self::CHATS_LOG_PATH, $logData, FILE_APPEND);
        }
    }
 
}
index.php
$bot = new OurBot($ubillingConfig->getAlterParam('OURBOT_TOKEN'));
$bot->setDebug($ubillingConfig->getAlterParam('OURBOT_DEBUG'));
$bot->hookAutosetup($ubillingConfig->getAlterParam('OURBOT_AUTOSETUP'));
 
$commands = array(
    'привіт' => 'actionHelloName'
);
 
$bot->setActions($commands);
$bot->setPhotoHandler('actionPhotoWow');
$bot->listen();

Інтерфейс та всіляке таке інше

Реакція на текстові команди, це звичайно неймовірно круто і олдскульно, але нормальні люди звикли до нормального інтерфейсу. Наприклад до кнопочок. От натискаєш кнопочку і щось відбувається. Бачили колись таке? :)

Відкриваємо таємницю - в більшості випадків, кнопочка це теж текст. Який просто набирається кастомною клавіатурою. Давайте навчимось користуватись ними, на прикладі нашого наступного бота. Нехай це буде “бот-порадник” котрий при тицянні на кнопочки видає неймовірні поради на всі випадки життя. Добре? Заодно без палєва продемонструємо, що на одному й тому ж фреймворку може в сусідніх модулях жити нелімітована кількість ботів (насправді, і в одному й тому ж модулі, вони всі можуть бути, але це шиза) . Вогонь, так? ;)

$ mkdir modules/general/poradnyk
$ touch modules/general/poradnyk/index.php
$ touch modules/general/poradnyk/module.php
$ touch api/libs/api.poradnyk.php

малюємо його конфіг в config/alter.ini

alter.ini
PORADNYK_TOKEN="252624204:89929232322221"
PORADNYK_DEBUG=1
PORADNYK_AUTOSETUP=1

заповнюємо опис модуля в modules/general/poradnyk/module.php

module.php
<?php
 
$this->registerModule($module, 'main', __('AdviceBot'), 'Author name');

збираємо імплементацію в api/libs/api.poradnyk.php

api.poradnyk.php
<?php
 
class Poradnyk extends WolfDispatcher {
 
    /**
     * Some predefined text-routes aka actions
     */
    const ROUTE_INIT = '/start';
    const ROUTE_ADVICE_UA = '🇺🇦 Порада';
    const ROUTE_ADVICE_RU = '🇷🇺 Совет';
    const GROUTE_ADVICE_UA = '!порада';
    const GROUTE_ADVICE_RU = '!совет';
 
    /**
     * Renders custom keyboard
     * 
     * @return void
     */
    protected function actionKeyboard() {
        $buttons[] = array(self::ROUTE_ADVICE_UA, self::ROUTE_ADVICE_RU);
        $keyboard = $this->telegram->makeKeyboard($buttons);
        $this->reply('Шо?', $keyboard);
    }
 
    /**
     * Gets awesome advice and sends message with it to current chat
     * 
     * @return void
     */
    protected function actionAdvice() {
        $fga = new OmaeUrl('http://ubilling.net.ua/fga/api/random/?lang=ua');
        $advice = $fga->response();
        $advice = json_decode($advice, true);
        $advice = $advice['text'];
        $this->reply($advice);
    }
 
    /**
     * Gets awasome advice in russian
     * 
     * @return void
     */
    protected function actionNahuy() {
        $this->replyTo('Нахуй иди, вот тебе совет.');
    }
 
}

Та контроллер цього всього в modules/general/poradnyk/index.php

index.php
 
<?php
 
// Bot instance creation
$bot = new Poradnyk($ubillingConfig->getAlterParam('PORADNYK_TOKEN'));
$bot->setDebug($ubillingConfig->getAlterParam('PORADNYK_DEBUG'));
$bot->hookAutosetup($ubillingConfig->getAlterParam('PORADNYK_AUTOSETUP'));
 
// Commands/actions for private chats
$commands = array(
    $bot::ROUTE_INIT => 'actionKeyboard',
    $bot::ROUTE_ADVICE_UA => 'actionAdvice',
    $bot::ROUTE_ADVICE_RU => 'actionNahuy'
);
 
// Group-chat commands is text-only and starts with "!" symbol.
// Using custom keyboards in group chats isn`t best practice.
$groupCommands = array(
    $bot::GROUTE_ADVICE_UA => 'actionAdvice',
    $bot::GROUTE_ADVICE_RU => 'actionNahuy',
);
 
 
$bot->setActions($commands);
$bot->setGroupActions($groupCommands);
$bot->listen();

Йдемо за URL нашого контроллера задля встановлення веб-хука. Можемо до речі браузером, можемо тупо curl-ом. Не важливо. Результат буде один.

Все, тестуємо:

Як бачимо, наш бот-порадник чудово реагує на натискання кнопок та видає поради на будь-які випадки життя як державною так і російською мовами за вибором користувача.

Не складно помітити, що actionKeyboard() викликається згідно команди “/start” котра завжди прилітає до бота на початку розмови з ним. В групових чатах (це ті, котрі $this→chatType!=private) клавіатуру не використовуємо, а орієнтуємось на текстові команди “!порада” та “!совет” пушачи цей набір команд в структуру setGroupActions котрий автоматично повністю замінює собою структуру setActions для цих самих групових чатів. Власне, якщо вам захочеться розділювати кнопочки кастомної клавіатури по рядах, то керується це наступним чином:

 $buttons[] = array(self::ROUTE_ADVICE_UA, self::ROUTE_ADVICE_RU);
 $buttons[] = array('🙏 Помолитись');

або якось так:

        $buttons[] = array('🔥 Вогонь','🌊 Вода','☁️ Повітря');
        $buttons[] = array(__('Use i18n Luke!'));
        $buttons[] = array('Тут тупо текст','А ось ще одна','Типу так');

з отаким от результатом

Чому на кнопочках стараємось використовувати емоджі? Власне з двох очевидних причин:

  1. Стараємось уникати випадкових реакцій бота, на рандомні повідомлення в чаті
  2. Красівоє

Також, насправді, ви не зобов`язані самі збирати клавіатуру повністю самі, викликаючи $this→telegram→makeKeyboard(). Для цього є зручний метод castKeyboard() що одразу відсилає клавіатуру до чатику

  $buttons[] =array('Кнопка 1','Кнопка 2');
  $buttons[] =array('Кнопка 3','Кнопка 4');
 
  $this->castKeyboard($buttons, 'Що робимо далі?'));

Про складне

Так, а зараз видихніть, помоліться

та зосередьтесь.

Будемо говорити про не самі очевидні речі. Про такі як “структура даних повідомлень”, як параметризувати наші обробники подій, ловити фоточки, та всіляке таке інше.

Отож, розпочнемо з параметризації ваших обробників. Якщо у випадку, з зовнішніми функціями вони просто викликаються як “function_name(повідомлення)” ви можете задефайнити її якось так

function doSomething($argument) {
  //тут далі робимо щось з цими даними, парсимо текст, наприклад.
}

То методи викликаються в лоб взагалі без параметрів. Зроблено це так, задля економії пам`яті. Звісно, навіщо плодити копії цих даних у середині об'єкта який і так вже їх отримав? Для роботи з “повідомленням” існує внутрішня структура $this→receivedData та захищений метод $this→message() котрий повертає її копію, що може бути зручним, якщо ви захочете якимось чином модифікувати дані прямо в цій структурі, але залишити їх незмінними для усіх інших подальших методів, що можуть бути виконані обробником. На практиці в коді ви це можете використовувати якось так:

    protected function handleEmptyAction() {
        if (!empty($this->receivedData['text'])) {
            $this->reply($this->receivedData['text']);
        }
    }

Власне все що отримує бот в даних хука, ми рахуємо “повідомленням” формат якого ідентичний, для повідомлень отриманих в приватних, групових та групових чатах, а також і для постів в каналах, в незалежності від вмісту самих повідомлень. Цю ж структуру, ви можете бачити і в дебаг-лозі при увімкненій відладці. Також, з метою забезпечення більшої зручності та лаконічності коду, деякі важливі значення з нього автоматично вже замаплено на приватні проперті.

Ось трохи детальніше, про те, що міститься всередині

Array
(
    [message_id] => (int) 666 - ідентифікатор повідомлення. Воно ж зберігається в проперті this->messageId 
                                та на нього за замовчуванням відповідає this->replyTo().
    [from] => Array
        (
            [id] => (int) 7777777777 - chatId користувача від якого надійшло повідомлення.
            [first_name] => (string) Микола Петренко -  iм`я користувача 
            [username] => (string) l33tmykola - юзернейм користувача
            [language_code] => (string) uk - мова клієнту користувача, на кшталт uk/ru/en
        )
 
    [chat] => Array
        (
            [id] => (int) 7777777777 - chatId чату з яким ведеться діалог. У випадку приватного чату він співпадає з [from][id].
                                       У випадку, якщо це не приватний чат, там знаходиться власне ID групи, супергрупи чи каналу (вони від`ємні).
                                       Для зручності це значення, знаходиться завжди у внутрішній проперті this->chatId. На нього ж за замовчуванням відповідають
                                       швидкі обгортки типу this->reply() та this->replyTo()
            [type] => (string) private  - тип чату, власне вони бувають: private (лічка), group (закрита група), supergroup (відкрита група), channel (канал)
        )
 
    [date] => (int) 1660393421 - дата отримання повідомлення у вигляді unixtimestamp
    [text] => (string) test message - не повірите, це текст повідомлення. Сюди ж маппляться усілякі caption-и картинок, тощо. Загалом вони всі [text]
    [photo] => array() - структура, що описує зображення, у випадку якщо воно таки отримано з повідомленням. Буде розглянуто окремо, трохи згодом.
    [document] => array() - виглядає приблизно як і photo тільки містить файлики з якимось mime_type. стосується також voice, audio, video_note
    [voice] => array() - звукові повідомлення, можете одразу таких користувачів додавати в банліст ;)
    [audio] => array() - аудіо файлики.
    [video_note] => array() - коротки відео, типу сторізів.
    [location] => array() - дані про геолокацію, з координатами
    [sticker] => array() - стікери, біс зна що з ними робити.
    [new_chat_member] => array() - структура котра заповнюється при появі користувача в груповому чаті. 
                                   Також вона заповнюється і при додаванні самого бота в груповий чат. 
                                   Плоский массив, містить поля: id, is_bot, first_name, username, language_code, is_premium - користувачі
                                                                 id, is_bot, first_name, username - інші боти
    [left_chat_member] => array() - структура, котра містить дані про користувача, що вийшов з чату. Аналогічна попередній.
    [reply_to_message] => array() - у випадку, якщо поточне повідомлення, є відповіддю на якесь інше повідомлення, містить точно таку саму структуру "повідомлення"
                                    як і ось ця, поточна, зі всіма даними повідомлення, на яке є відповіддю поточне.
)

У випадку, якщо крім просто тексту, до вас прилетіло ще щось, буде заповнено структури фоточок, документів, тощо. Виглядає це якось так:

   [text] => це текстовий опис фоточки
    [photo] => Array
        (
            [0] => Array
                (
                    [file_id] => AgACAgIAAxkBAAIFkWL3nxfnsM4SlGHiXNi4qDPdX2bmAAKsvjEbdRC4S58-T_p7q3ZCAQADAgADcwADKQQ
                    [file_unique_id] => AQADrL4xG3UQuEt4
                    [file_size] => 1982
                    [width] => 90
                    [height] => 89
                )
 
            [1] => Array
                (
                    [file_id] => AgACAgIAAxkBAAIFkWL3nxfnsM4SlGHiXNi4qDPdX2bmAAKsvjEbdRC4S58-T_p7q3ZCAQADAgADbQADKQQ
                    [file_unique_id] => AQADrL4xG3UQuEty
                    [file_size] => 19215
                    [width] => 320
                    [height] => 315
                )
 
            [2] => Array
                (
                    [file_id] => AgACAgIAAxkBAAIFkWL3nxfnsM4SlGHiXNi4qDPdX2bmAAKsvjEbdRC4S58-T_p7q3ZCAQADAgADeAADKQQ
                    [file_unique_id] => AQADrL4xG3UQuEt9
                    [file_size] => 19267
                    [width] => 324
                    [height] => 319
                )
 
        )

З самого вигляду цього всього, доволі очевидно, що це массив з превьюшками цієї фоточки, з різною якістю, розмірами та ідентифікаторами файликів, які можна завантажити собі. Також очевидно, що ви не дуже хочете працювати з цим всім мануально, тому двома сантиметрами нижче, ви довідаєтесь про зручні обгортки які дозволять вам дуже елегантно та лаконічно робити з отриманими зображеннями те, що вам скоріш за все захочеться з ними робити.

Робота з зображеннями

Перш за все, необхідно пам'ятати що зображення до нас можуть прийти двома шляхами. Як “швидко надіслати зображення” так і “надіслане без стиснення”. Відповідно фігурувати вони можуть або як частина структури [photo] так і як частина [document] з відповідними зображенням mime-типами. Думати про це все, ми в принципі не хочемо. Саме тому, з цієї причини для роботи з зображеннями є дуже зручні обгортки. Давайте розглянемо на прикладі мінімалістичного боту, котрий на будь-яке повідомлення з зображенням, буде:

  1. Завантажувати це зображення собі
  2. Якось його модифікувати
  3. Відсилати в чатік назад уже спохабленим

От якось так

class DemoBot extends WolfDispatcher {
 
    /**
     * Для відсилання зображень, локація, звідки ми їх шлемо повинна бути доступною з вебу.
     */
    const IMG_SAVE_PATH = 'tmp/';
    const IMG_URL = 'https://наш_хост/dev/demobot/';
 
    /**
     * Catches and returns processed image to user
     * 
     * @return void
     */
    protected function actionProcessImage() {
        // Якусь фоточку зловлено? Насправді PhotoHandler метод відбудеться тільки за цієї умови
        // так, що ця перевірка надлишкова тут. Просто як ілюстрація.
        if ($this->isPhotoReceived()) {
            // Зберігаємо зображення до тимчасової директорії. Також ми могли б використати getPhoto()
            // задля того, щоб просто отримати в змінну вміст самого зображеня, а потім з ним щось робити, 
            // але навіщо, якщо є швидкий savePhoto()?
            $photoSaveResult = $this->savePhoto(self::IMG_SAVE_PATH . $this->messageId . '.jpg');
            // перевіряємо чи воно взагалі збереглось?
            if ($photoSaveResult) {
                //завантажуємо зображення
                $image = imagecreatefromjpeg($photoSaveResult);
                //робимо з зображенням різні штуки
                imagefilter($image, IMG_FILTER_BRIGHTNESS, 70);
                imagefilter($image, IMG_FILTER_GRAYSCALE);
                imagefilter($image, IMG_FILTER_MEAN_REMOVAL, 10);
                imagefilter($image, IMG_FILTER_SCATTER, 3, 5);
                imagefilter($image, IMG_FILTER_NEGATE);
 
                //перезберігаємо вже оброблене зображення
                $newImageName = $photoSaveResult . '_processed.jpg';
                imagejpeg($image, $newImageName);
                $fullImageUrl = self::IMG_URL . $newImageName;
                //збираємо повідомлення
                $message = 'sendPhoto:[' . $fullImageUrl . ']{все тлєн}';
                //відповідаємо ним в чатік
                $this->reply($message);
            }
        }
    }
 
}
 
$bot = new DemoBot($ubillingConfig->getAlterParam('DEMOBOT_TOKEN'));
$bot->setPhotoHandler('actionProcessImage');
$bot->listen();

З повністю очікуваним результатом

Окей, а якщо ми не хочемо робити тільки “тлєн”, а хочемо з зображеннями робити якесь різне і трохи інстаграмне, в залежності від тексту, який фігурує в описі зображення? Ну короче хочемо не один метод викликати для отриманого зображення, а декілька різних в залежності від того, що там від нас хочуть? Ну тоді setPhotoHandler будемо використовувати тільки для завантаження зображень, а весь процесинг параметрично винесемо в окремі методи. Модифікувати збережене зображення будемо в захищеній проперті $image.

class ImgFiltersBot extends WolfDispatcher {
 
    /**
     * Contains per-user image file path
     *
     * @var string
     */
    protected $userImageFilePath = '';
 
    /**
     * Contains GD image resource to apply some filters
     *
     * @var mixed
     */
    protected $image = '';
 
    /**
     * Some predefined data here
     */
    const IMG_SAVE_PATH = 'tmp/';
    const IMG_URL = 'https://somehost.com/dev/imgfiltersbot/';
    const FILTER_MARK = '🔥 ';
 
    /**
     * Sets user image path on any input action
     * 
     * @return void
     */
    protected function setImagePath() {
        $this->userImageFilePath = self::IMG_SAVE_PATH . $this->receivedData['from']['id'] . '.jpg';
    }
 
    /**
     * Catches and saves one image per user
     * 
     * @return void
     */
    protected function actionCatchImage() {
        $this->setImagePath();
        $photoSaveResult = $this->savePhoto($this->userImageFilePath);
        if ($photoSaveResult) {
            $this->replyTo(__('Ok, we saved this image. Which filters will be applied'));
            $this->actionKeyboard();
        }
    }
 
    /**
     * Renders available filters keyboard if image loaded
     * 
     * @return void
     */
    protected function actionKeyboard() {
        $this->setImagePath();
        //user loaded any image?
        if (file_exists($this->userImageFilePath)) {
 
            $availableFilters = array(
                'tender', 'dream', 'frozen', 'forest', 'rain', 'orangepeel', 'darken', 'summer', 'retro', 'country', 'washed', 'freshblue',
                'everglow', 'hermajesty', 'concentrate', 'vintage', 'blur', 'blackwhite', 'antique', 'gray', 'boost', 'fuzzy', 'aqua', 'sepia'
            );
 
            $buttonsInRow = 3;
            $i = 1;
            $buttons = array();
            $buttonsRow = array();
            foreach ($availableFilters as $io => $each) {
 
                if ($i <= $buttonsInRow) {
                    $buttonsRow[] = self::FILTER_MARK . $each;
                } else {
                    $buttons[] = $buttonsRow;
                    $buttonsRow = array();
                    $i = 0;
                }
                $i++;
            }
            $this->castKeyboard($buttons, __('Select filter'));
        } else {
            $this->reply(__('Upload any image please. I`m waiting...') . ' 🤔');
        }
    }
 
    /**
     * Returns processed image to user
     * 
     * @return void
     */
    protected function returnProcessedImage() {
        $this->setImagePath();
        //is image loaded?
        if (!empty($this->image)) {
            $newImageName = $this->userImageFilePath . '_processed_' . time() . '.jpg';
            //re-save new image if not exists
            if (!file_exists($newImageName)) {
                imagejpeg($this->image, $newImageName);
            }
            $fullImageUrl = self::IMG_URL . $newImageName;
            $message = 'sendPhoto:[' . $fullImageUrl . ']';
            $this->reply($message);
        }
    }
 
    /**
     * Runs selected filter and returns image to user
     */
    protected function applyFilter() {
        $this->setImagePath();
        if (file_exists($this->userImageFilePath)) {
            if (!empty($this->receivedData['text'])) {
                $filterName = str_replace(self::FILTER_MARK, '', $this->receivedData['text']);
                $filterName = trim($filterName);
                if (!empty($filterName)) {
                    if (method_exists($this, $filterName)) {
                        //load image from filesystem
                        $this->image = imagecreatefromjpeg($this->userImageFilePath);
                        $this->$filterName();
                        $this->returnProcessedImage();
                        $this->actionKeyboard();
                    } else {
                        $this->reply('🙈 ' . __('Not existing image filter!'));
                    }
                }
            }
        }
    }
 
    /**
     * Some image instagram-like filters here.
     */
    protected function tender() {
        imagefilter($this->image, IMG_FILTER_CONTRAST, 5);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 80, 20, 40, 50);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 40, 40, 100);
        imagefilter($this->image, IMG_FILTER_SELECTIVE_BLUR);
    }
 
    protected function dream() {
        imagefilter($this->image, IMG_FILTER_COLORIZE, 150, 0, 0, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 50, 0, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_GAUSSIAN_BLUR);
    }
 
    protected function frozen() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -15);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 0, 100, 50);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 0, 100, 50);
        imagefilter($this->image, IMG_FILTER_GAUSSIAN_BLUR);
    }
 
    protected function forest() {
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 0, 150, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 0, 150, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_SMOOTH, 10);
    }
 
    protected function rain() {
        imagefilter($this->image, IMG_FILTER_GAUSSIAN_BLUR);
        imagefilter($this->image, IMG_FILTER_MEAN_REMOVAL);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 80, 50, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_SMOOTH, 10);
    }
 
    protected function orangepeel() {
        imagefilter($this->image, IMG_FILTER_COLORIZE, 100, 20, -50, 20);
        imagefilter($this->image, IMG_FILTER_SMOOTH, 10);
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -10);
        imagefilter($this->image, IMG_FILTER_CONTRAST, 10);
        imagegammacorrect($this->image, 1, 1.2);
    }
 
    protected function darken() {
        imagefilter($this->image, IMG_FILTER_GRAYSCALE);
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -50);
    }
 
    protected function summer() {
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 150, 0, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 25, 50, 0, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
    }
 
    protected function retro() {
        imagefilter($this->image, IMG_FILTER_GRAYSCALE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 100, 25, 25, 50);
    }
 
    protected function country() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -30);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 50, 50, 50, 50);
        imagegammacorrect($this->image, 1, 0.3);
    }
 
    protected function washed() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, 30);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, -50, 0, 20, 50);
        imagefilter($this->image, IMG_FILTER_NEGATE);
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, 10);
        imagegammacorrect($this->image, 1, 1.2);
    }
 
    protected function freshblue() {
        imagefilter($this->image, IMG_FILTER_CONTRAST, -5);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 20, 0, 80, 60);
    }
 
    protected function everglow() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -30);
        imagefilter($this->image, IMG_FILTER_CONTRAST, -5);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 30, 30, 0);
    }
 
    protected function hermajesty() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -10);
        imagefilter($this->image, IMG_FILTER_CONTRAST, -5);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 80, 0, 60);
    }
 
    protected function concentrate() {
        imagefilter($this->image, IMG_FILTER_GAUSSIAN_BLUR);
        imagefilter($this->image, IMG_FILTER_SMOOTH, -10);
    }
 
    protected function vintage() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, 10);
        imagefilter($this->image, IMG_FILTER_GRAYSCALE);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 40, 10, -15);
    }
 
    protected function blur() {
        imagefilter($this->image, IMG_FILTER_SELECTIVE_BLUR);
        imagefilter($this->image, IMG_FILTER_GAUSSIAN_BLUR);
        imagefilter($this->image, IMG_FILTER_CONTRAST, -15);
        imagefilter($this->image, IMG_FILTER_SMOOTH, -2);
    }
 
    protected function blackwhite() {
        imagefilter($this->image, IMG_FILTER_GRAYSCALE);
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, 10);
        imagefilter($this->image, IMG_FILTER_CONTRAST, -20);
    }
 
    protected function antique() {
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, 0);
        imagefilter($this->image, IMG_FILTER_CONTRAST, -30);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 75, 50, 25);
    }
 
    protected function gray() {
 
        imagefilter($this->image, IMG_FILTER_CONTRAST, -60);
        imagefilter($this->image, IMG_FILTER_GRAYSCALE);
    }
 
    protected function boost() {
        imagefilter($this->image, IMG_FILTER_CONTRAST, -35);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 25, 25, 25);
    }
 
    protected function fuzzy() {
        $gaussian = array(
            array(1.0, 1.0, 1.0),
            array(1.0, 1.0, 1.0),
            array(1.0, 1.0, 1.0)
        );
 
        imageconvolution($this->image, $gaussian, 9, 20);
    }
 
    protected function aqua() {
 
        imagefilter($this->image, IMG_FILTER_COLORIZE, 0, 70, 0, 30);
    }
 
    protected function sepia() {
        imagefilter($this->image, IMG_FILTER_GRAYSCALE);
        imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -10);
        imagefilter($this->image, IMG_FILTER_CONTRAST, -20);
        imagefilter($this->image, IMG_FILTER_COLORIZE, 60, 30, -15);
    }
 
}
 
$bot = new ImgFiltersBot($ubillingConfig->getAlterParam('BOT_TOKEN'));
 
$commands = array(
    '/start' => 'actionKeyboard',
    $bot::FILTER_MARK => 'applyFilter'
);
 
$bot->hookAutosetup(true);
$bot->setActions($commands);
$bot->setPhotoHandler('actionCatchImage');
 
$bot->listen();

а ось, як це все працює на практиці:

Використання поза фреймворком

Також ви можете використовувати WolfDispatcher просто інклудячи тільки дві бібліотеки:

$ mkdir ourbot
$ cd ourbot/
$ mkdir exports
$ echo "deny from all" > exports/.htaccess
$ chmod 777 exports
$ wget https://raw.githubusercontent.com/nightflyza/WolfDispatcher/master/api.wolfgram.php
$ wget https://raw.githubusercontent.com/nightflyza/WolfDispatcher/master/api.wolfdispatcher.php
$ touch index.php

і все працює:

index.php
<?php
 
// low-level Telegram API implementation
require_once('api.wolfgram.php');
// WolfDispatcher lib
require_once('api.wolfdispatcher.php');
 
class OurBot extends WolfDispatcher { }
 
$bot = new OurBot('252114203:AA111231235644444654');
$bot->hookAutosetup();
$bot->listen();

Власне тут репозиторій на GitHub зі всім, що необхідно.