Skip to content

Commit

Permalink
Improved callables (#27)
Browse files Browse the repository at this point in the history
* Update symfony/http-client requirement from ^6.2 to ^7.0

Updates the requirements on [symfony/http-client](https://github.com/symfony/http-client) to permit the latest version.
- [Release notes](https://github.com/symfony/http-client/releases)
- [Changelog](https://github.com/symfony/http-client/blob/7.0/CHANGELOG.md)
- [Commits](symfony/http-client@v6.2.0...v7.0.0)

---
updated-dependencies:
- dependency-name: symfony/http-client
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>

* Resolving callable definitions

* docs

* docs

* Userland update handlers should not implement UpdateHandlerInterface anymore

* Suppress psalm error check

* More phpdocs and formatting

* Allow UpdateHandlerInterface object as a route action

* remove redundant check

* Make RuleDynamic changes backward compatible

* code style

* Friendly exceptions, variable naming and more strict typing

* docs

* Request tags

* Router exceptions tests and bugfixes

* Trigger a deprecation warning when an action implements UpdateHandlerInterface

* More friendly exceptions

* Tests for attribute value exceptions

* Resolve not typed arguments from attributes

* UpdateAttribute docs

* Fix readme formatting

* More docs

* One more callable resolver test

* Trigger a deprecation warning when an action implements UpdateHandlerInterface

* Idle docs changes

* Add complete configuration

* Fix params name and add docs for yiisoft/config integration

* Types idle fix

* Request tags docs and bugfixes

* idle

* Rename TelegramRequestDecorator to TelegramRequestEnriched and require TelegramRequestInterface as parameters in ResponseInterface methods

* log exceptions between update handles instead of throwing them

* Improve readme

* Improve readme

* Improve readme

* Improve readme

* Improve readme

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
  • Loading branch information
viktorprogger and dependabot[bot] authored Sep 14, 2024
1 parent 3f27db7 commit 530241d
Show file tree
Hide file tree
Showing 61 changed files with 2,183 additions and 378 deletions.
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false
indent_size = 2

[*.yml]
indent_size = 2
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
# Botasis Runtime Change Log

## 0.12.0

- `UpdateAttribute` is added, so router dynamic rules and update handlers may use typed arguments received from an `Update` object
- Added Extended Callable Definitions support to dynamic route rules.
- Added Extended Callable Definitions support to route actions.
Usage of UpdateHandlerInterface as an action is now deprecated, and this ability will be removed in future versions.
- Response tags and subscriptions are added.
- Params section renamed from `botasis/telegram-bot` to `botasis/runtime`.
- `yiisoft/config`-compatible configuration added for all possible classes. Dynamic part is moved to params.
- `GetUpdatesCommand` don't throw exceptions anymore. It just logs them to a PSR logger instead.
- More docs are added

## 0.11.3

- Bugfix: `StateJson` didn't correctly set data when data is not a string

## 0.11.2

- More PhpDocs for `StateRepositoryInterface` and `StateMiddleware`

## 0.11.1

- State management is added. Users now may use chat/user state in router rules and update handlers

## 0.11.0

- `UserId` value object is removed. Using `string` instead.

## 0.10.0

- Documentation is added.
- UserRepositoryInterface is removed as it's not responsibility of the Runtime library and is not used here.
- Routes are now objects instead of arrays
- Fix failed tests
- PhpUnit cache is removed from the repository
- Telegram Update variables are completely renamed from $request to $update
- Telegram Update variables are completely renamed from $request to $update

## 0.11.0

Expand Down
177 changes: 168 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,138 @@ Botasis Runtime is a powerful and versatile PHP library designed to streamline t
It serves as a foundational framework for building Telegram bots by providing essential abstractions and features,
making it easier than ever to create interactive and intelligent chatbots.

## Example

Here is an example of a simple Telegram bot that will send currency exchange rates by your request, but only if you
already paid for this functionality.

1. Create routes:
```php
[
(new Group(
// This bot will work in private chats only
new RuleDynamic(static fn(Update $update) => $update->chat->type === ChatType::PRIVATE),
...[
'/start' => new Route(
new RuleStatic('/start'),
[StartAction::class, 'handle'],
),
'/pay' => new Route(
new RuleStatic('/pay'),
[PayAction::class, 'handle'],
),
'rates requests' => (new Route(
new RuleDynamic(
static fn(Update $update): bool => preg_match('#^/rates \w+ \w+$#', $update->requestData ?? '') === 1,
),
[RatesAction::class, 'handle'],
))->withMiddlewares(PaidAccessMiddleware::class),
],
))->withMiddlewares(UserRegisterMiddleware::class),
]
```
Here we map three commands to their corresponding actions. `/start` and `/pay` commands are static routes. That means
they will be mapped only if they match exactly to what did user send.
On the other hand, `/rates` command is a dynamic one and should look like `/rates USD GBP`.
2. Create command handlers.
1. The most simple one is `StartAction`. It will only send a greeting message.
```php
final readonly class StartAction
{
public function handle(Update $update): ResponseInterface
{
return (new Response($update))
->withRequest(
new Message(
// You should properly escape all special characters to send a markdown message
'Hello\\! Use \\/pay command to get premium\\, and then use \\/rates command ' .
'to see currency exchange rates\\. I\\.e\\. `/pay USD GBP`\\.',
MessageFormat::MARKDOWN,
$update->chat->id,
),
);
}
}
```
2. Accepting payments is out of scope of this example. I will skip the `PayAction` handler,
as there is nothing interesting in comparison to `RatesAction`.
3. `RatesAction` handler:
```php
final readonly class RatesAction
{
public function __construct(private readonly RatesService $ratesService) {}

public function handle(
Update $update,
#[UpdateAttribute('user')] User $user,
): ResponseInterface {
// User sent a request like "/rates USD GBP", so we need to get USD and GBP from the request
[, $currency1, $currency2] = explode(' ', $update->requestData);

$user->saveRequestHistory($currency1, $currency2);
$rate = $this->ratesService->getRate($currency1, $currency2);
$date = date('Y-m-d H:i:s');

// send a message as a response
return (new Response($update))
->withRequest(
new Message(
"1 $currency1 = $rate $currency2 on $date. To get a reverse rate, " .
"use /rates $currency2 $currency1 command.",
MessageFormat::TEXT,
$update->chat->id,
),
);
}
}
```
3. Create middlewares.
1. `UserRegisterMiddleware` is assigned to the outer group. So it's always executed before each handler. It registers
a user and adds it to an Update object as an attribute:
```php
final class UserRegisterMiddleware implements MiddlewareInterface
{
public function __construct(private UserRepository $repository)
{
}

public function process(Update $update, UpdateHandlerInterface $handler): ResponseInterface
{
// Repository either finds a user or creates a new one
$holder = $this->repository->getUser($update->user, $update->chat->id);

// now $update->getAttribute('user') contains a User object
return $handler->handle($update->withAttribute('user', $holder));
}
}
```
Because of this middleware, request handlers may use `#[UpdateAttribute('user')]` attribute and get a typed `User` object (see `RatesAction::handle()`).
2. `PaidAccessMiddleware` won't let to make a rates request if the user is not a premium user.
It's also ready to be attached to any other paid endpoint.
```php
final class PaidAccessMiddleware implements MiddlewareInterface
{
public function process(Update $update, UpdateHandlerInterface $handler): ResponseInterface
{
/** @var User $user */
$user = $update->getAttribute('user');
if (!$user->isPremium()) {
return (new Response($update))
->withRequest(new Message(
'Only premium users can use this command',
MessageFormat::TEXT,
$update->chat->id,
));
}

return $handler->handle($update);
}
}
```

With Botasis Runtime you shouldn't think about Telegram infrastructure. Just write your business logic as you already do
for HTTP request handling!

## Key Features

- **Middleware Stack:** Botasis Runtime offers a robust middleware system, allowing you to easily define and organize
Expand All @@ -23,7 +155,7 @@ making it easier than ever to create interactive and intelligent chatbots.
bot application, Botasis Runtime adapts to your project's needs.

- **Extensibility:** Extend and customize the behavior of your Telegram bot
by adding your own middleware, handlers, and custom logic. It has all extension points you may ever need.
by adding your own middlewares, actions, and custom logic. It has all extension points you may ever need.

- **Configuration:** Fine-tune bot settings, routing rules, and middleware
stacks to create a bot that behaves exactly as you envision. No hard code, everything is configurable.
Expand All @@ -32,6 +164,13 @@ making it easier than ever to create interactive and intelligent chatbots.
to make changes and enhancements easily, ensuring your bot remains adaptable
to new features and user interactions.

- **Supports best practices:**
- **PSR-enabled**. Not a single hard-coded dependency. You may use any PSR implementations you want.
- **SOLID code**. You will especially like Dependency Inversion Principle implementation, as it allows you to pass
anything anywhere and switch implementations with a snap of your fingers.
- **Long-running enabled**. It is ready to be used with **RoadRunner**, **Swoole** and others long-running
application engines. That means Botasis Runtime has *no any stateful services* and *never computes one thing twice*.

## Quick Start

The quickest way to start your app with Botasis is to use the [Botasis Application Template](https://github.com/botasis/bot-template).
Expand Down Expand Up @@ -111,10 +250,10 @@ If you don't want to use it, or you want to embed Botasis into your existing app
);

/**
* Routes definition. Here we define a route for the /start message. The HelloHandler should implement the {@see UpdateHandlerInterface}.
* Routes definition. Here we define a route for the /start message. The HelloAction will be instantiated by a DI container.
*/
$routes = [
new Route(new RuleStatic('/start'), HelloHandler::class),
new Route(new RuleStatic('/start'), [HelloAction::class, 'handle']),
];

/**
Expand All @@ -126,27 +265,47 @@ If you don't want to use it, or you want to embed Botasis into your existing app
$application = new Application($emitter, new DummyUpdateHandler(), $middlewareDispatcher);
```
</details>
3. Customize your bot by registering middleware, handlers, and routes based on
3. Customize your bot by registering middleware, actions, and routes based on
your bot's behavior and requirements.
4. Start receiving updates. You can use the [GetUpdatesCommand](src/Console/GetUpdatesCommand.php) to pull
updates from Telegram API while working locally or [SetTelegramWebhookCommand](src/Console/SetTelegramWebhookCommand.php) to set
your bot's webhook address, so Telegram will send you updates itself.

That's it! You've now set up the foundation for your bot using Botasis Runtime.
You can continue to enhance your bot's functionality by customizing its
handlers, middleware, and routes to create engaging and interactive experiences
actions, middleware, and routes to create engaging and interactive experiences
for your users.


## Features
### 1. Routing
You can create routes and sub-routes for your Telegram bot. Every route consists of two parts;
- A `Rule`. When an `Update` comes from Telegram, `Router` checks whether it satisfy a rule of every route. Once a such route
was found, its `Action` is executed. There are two types of rules:
- `RuleStatic`. It maps to a message or a callback data in an Update. When a message or a callback comes from Telegram,
it's text compared to every existing `RuleStatic`. Creation of such a rule is very simple:
`new RuleStatic('/command')`. And only when there is no suitable static rule, we go further to `RuleDynamic` list.
- `RuleDynamic`. In contrast to `RuleStatic`, this rule type executes a callable on every `Update`. Creation of such a
rule may look like this:
`new RuleDynamic(static fn(Update $update) => str_starts_with($update->requestData ?? '', '/start@myBot'))`.
Such a callable **MUST** return a boolean value. Callable definition should follow the
[Extended Callable Definitions](./docs/extended-callable-definitions.md) format.

[Defining routes in detail](./docs/defining-routes).
- An `Action`. It's a callable, which may be defined in an equal way as a `RuleDynamic` callable.
But a route action return value **MUST** be either `null`/`void` or `ResponseInterface`. In all the other cases Router will
throw an exception.

### 1. Attributes usage in routing

### 1. State management
When your application requires handling chat or user state, this feature is essential. Follow these four steps to use it:

1. Implement [StateRepositoryInterface](./src/State/StateRepositoryInterface.php).
You may use any existing implementation *(they will be implemented later)*.
2. Save user/chat state using the repository:
```php
final class CharacterNameCommandHandler implements UpdateHandlerInterface
final class CharacterNameCommandAction
{
public function __construct(private StateRepositoryInterface $repository) {}

Expand All @@ -165,7 +324,7 @@ When your application requires handling chat or user state, this feature is esse
}
```
3. Add [StateMiddleware](./src/State/StateMiddleware.php) before the router middleware.
This allows you to access the current state within your update handlers.
This allows you to access the current state within your router actions.
```php
$state = $update->getAttribute(\Botasis\Runtime\State\StateMiddleware::class);
```
Expand All @@ -174,10 +333,10 @@ When your application requires handling chat or user state, this feature is esse
4. Use state in routing:
```php
[
new Route(new RuleStatic('/set_name'), CharacterNameCommandHandler::class),
new Route(new RuleStatic('/set_name'), CharacterNameCommandAction::class),
new Route(
new RuleDynamic(static fn(Update $update) => $update->getAttributes(StateMiddleware::class)?->getData() === json_encode('setting-name')),
CharacterNameSetHandler::class,
CharacterNameSetAction::class,
),
]
```
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"psr/container": "^2.0",
"psr/event-dispatcher": "^1.0",
"psr/log": "^1.0.0||^2.0.0||^3.0.0",
"symfony/deprecation-contracts": "^3.5",
"yiisoft/friendly-exception": "^1.1",
"yiisoft/injector": "^1.0"
},
Expand All @@ -24,8 +25,9 @@
"phpunit/phpunit": "^10.1",
"roave/infection-static-analysis-plugin": "^1.16",
"symfony/console": "^6.2",
"symfony/http-client": "^6.2",
"symfony/http-client": "^7.0",
"vimeo/psalm": "^5.4",
"yiisoft/definitions": "^3.3",
"yiisoft/test-support": "^3.0"
},
"suggest": {
Expand Down
41 changes: 39 additions & 2 deletions config/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,58 @@

use Botasis\Client\Telegram\Client\ClientInterface;
use Botasis\Client\Telegram\Client\ClientPsr;
use Botasis\Runtime\Application;
use Botasis\Runtime\Event\IgnoredErrorHandler;
use Botasis\Runtime\Event\RequestTagsHandler;
use Botasis\Runtime\Middleware\Implementation\EnsureCallbackResponseMiddleware;
use Botasis\Runtime\Middleware\Implementation\RouterMiddleware;
use Botasis\Runtime\Middleware\MiddlewareDispatcher;
use Botasis\Runtime\Middleware\MiddlewareFactory;
use Botasis\Runtime\Middleware\MiddlewareFactoryInterface;
use Botasis\Runtime\Router\Router;
use Http\Message\MultipartStream\MultipartStreamBuilder;
use Psr\Http\Message\StreamFactoryInterface;
use Yiisoft\Definitions\DynamicReference;
use Yiisoft\Definitions\Reference;
use Yiisoft\Injector\Injector;

return [
MiddlewareFactoryInterface::class => MiddlewareFactory::class,
ClientInterface::class => [
'class' => ClientPsr::class,
'__construct()' => [
'token' => $params['botasis/telegram-bot']['bot token'],
'token' => $params['botasis/runtime']['bot token'],
],
],
IgnoredErrorHandler::class => [
'__construct()' => [
'ignoredErrors' => $params['botasis/telegram-bot']['errors to ignore'],
'ignoredErrors' => $params['botasis/runtime']['errors to ignore'],
],
],
RequestTagsHandler::class => [
'__construct()' => [
'tagsSuccess' => $params['botasis/runtime']['request tags']['success'],
'tagsError' => $params['botasis/runtime']['request tags']['error'],
],
],
Application::class => [
'__construct()' => [
'fallbackHandler' => Reference::to($params['botasis/runtime']['fallback handler']),
'dispatcher' => DynamicReference::to(static function (Injector $injector): MiddlewareDispatcher {
return ($injector->make(MiddlewareDispatcher::class))
->withMiddlewares(
EnsureCallbackResponseMiddleware::class,
RouterMiddleware::class,
);
}),
],
],
Router::class => [
'__construct()' => ['routes' => $params['botasis/runtime']['routes']],
],
MultipartStreamBuilder::class => [
'__construct()' => [
'streamFactory' => Reference::to(StreamFactoryInterface::class),
]
],
];
6 changes: 6 additions & 0 deletions config/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@

use Botasis\Runtime\Event\IgnoredErrorHandler;
use Botasis\Runtime\Event\RequestErrorEvent;
use Botasis\Runtime\Event\RequestSuccessEvent;
use Botasis\Runtime\Event\RequestTagsHandler;

return [
RequestSuccessEvent::class => [
[RequestTagsHandler::class, 'handleSuccess'],
],
RequestErrorEvent::class => [
[IgnoredErrorHandler::class, 'handle'],
[RequestTagsHandler::class, 'handleError'],
],
];
Loading

0 comments on commit 530241d

Please sign in to comment.