From 861227eb3476f54d9e46967ef24a2dd52968f013 Mon Sep 17 00:00:00 2001 From: Andrzej Nowicki Date: Tue, 29 Oct 2024 23:24:58 +0100 Subject: [PATCH 1/4] add support for ChatJoinRequest --- src/DTO/ChatInviteLink.php | 160 ++++++++++++++++++++++++++++++++ src/DTO/ChatJoinRequest.php | 103 ++++++++++++++++++++ src/Handlers/WebhookHandler.php | 18 ++++ 3 files changed, 281 insertions(+) create mode 100644 src/DTO/ChatInviteLink.php create mode 100644 src/DTO/ChatJoinRequest.php diff --git a/src/DTO/ChatInviteLink.php b/src/DTO/ChatInviteLink.php new file mode 100644 index 00000000..0f88c75b --- /dev/null +++ b/src/DTO/ChatInviteLink.php @@ -0,0 +1,160 @@ +> + */ +class ChatInviteLink implements Arrayable +{ + private string $inviteLink; + private User $creator; + private bool $createsJoinRequest; + private bool $isPrimary; + private bool $isRevoked; + private ?string $name = null; + private ?CarbonInterface $expireDate = null; + private ?int $memberLimit = null; + private ?int $pendingJoinRequestsCount = null; + private ?int $subscriptionPeriod = null; + private ?int $subscriptionPrice = null; + + private function __construct() + { + } + + /** + * @param array{ + * creator: array, + * invite_link: string, + * creates_join_request: bool, + * is_primary: bool, + * is_revoked: bool, + * name?: string, + * expire_date?: int, + * member_limit?: int, + * pending_join_requests_count?: int, + * subscription_period?: int, + * subscription_price?: int + * } $data + */ + public static function fromArray(array $data): ChatInviteLink + { + $invite = new self(); + + $invite->inviteLink = $data['invite_link']; + + $invite->creator = User::fromArray($data['creator']); + + $invite->createsJoinRequest = $data['creates_join_request']; + + $invite->isPrimary = $data['is_primary']; + + $invite->isRevoked = $data['is_revoked']; + + if (isset($data['name'])) { + $invite->name = $data['name']; + } + + if (isset($data['expire_date'])) { + /* @phpstan-ignore-next-line */ + $invite->expireDate = Carbon::createFromTimestamp($data['expire_date']); + } + + if (isset($data['member_limit'])) { + $invite->memberLimit = $data['member_limit']; + } + + if (isset($data['pending_join_requests_count'])) { + $invite->pendingJoinRequestsCount = $data['pending_join_requests_count']; + } + + if (isset($data['subscription_period'])) { + $invite->subscriptionPeriod = $data['subscription_period']; + } + + if (isset($data['subscription_price'])) { + $invite->subscriptionPrice = $data['subscription_price']; + } + + return $invite; + } + + public function inviteLink(): string + { + return $this->inviteLink; + } + + public function creator(): User + { + return $this->creator; + } + + public function createsJoinRequest(): bool + { + return $this->createsJoinRequest; + } + + public function isPrimary(): bool + { + return $this->isPrimary; + } + + public function isRevoked(): bool + { + return $this->isRevoked; + } + + public function name(): ?string + { + return $this->name; + } + + public function expireDate(): ?CarbonInterface + { + return $this->expireDate; + } + + public function memberLimit(): ?int + { + return $this->memberLimit; + } + + public function pendingJoinRequestsCount(): ?int + { + return $this->pendingJoinRequestsCount; + } + + public function subscriptionPeriod(): ?int + { + return $this->subscriptionPeriod; + } + + public function subscriptionPrice(): ?int + { + return $this->subscriptionPrice; + } + + public function toArray(): array + { + return array_filter([ + 'invite_link' => $this->inviteLink, + 'creator' => $this->creator->toArray(), + 'creates_join_request' => $this->createsJoinRequest, + 'is_primary' => $this->isPrimary, + 'is_revoked' => $this->isRevoked, + 'name' => $this->name, + 'expire_date' => $this->expireDate?->timestamp, + 'member_limit' => $this->memberLimit, + 'pending_join_requests_count' => $this->pendingJoinRequestsCount, + 'subscription_period' => $this->subscriptionPeriod, + 'subscription_price' => $this->subscriptionPrice, + ], fn ($value) => $value !== null); + } +} diff --git a/src/DTO/ChatJoinRequest.php b/src/DTO/ChatJoinRequest.php new file mode 100644 index 00000000..daef68b6 --- /dev/null +++ b/src/DTO/ChatJoinRequest.php @@ -0,0 +1,103 @@ +> + */ +class ChatJoinRequest implements Arrayable +{ + private int $userChatId; + private ?CarbonInterface $date = null; + private ?string $bio = null; + private ?ChatInviteLink $inviteLink = null; + private Chat $chat; + private User $from; + + private function __construct() + { + } + + /** + * @param array{ + * user_chat_id: int, + * date: int, + * bio?: string, + * invite_link?: array, + * chat: array, + * from: array, + * } $data + */ + public static function fromArray(array $data): ChatJoinRequest + { + $request = new self(); + + $request->userChatId = $data['user_chat_id']; + + if (isset($data['date'])) { + /* @phpstan-ignore-next-line */ + $request->date = Carbon::createFromTimestamp($data['date']); + } + + if (isset($data['bio'])) { + $request->bio = $data['bio']; + } + + if (isset($data['invite_link'])) { + /* @phpstan-ignore-next-line */ + $request->inviteLink = ChatInviteLink::fromArray($data['invite_link']); + } + + $request->chat = Chat::fromArray($data['chat']); + + $request->from = User::fromArray($data['from']); + + return $request; + } + + public function userChatId(): int + { + return $this->userChatId; + } + + public function date(): ?CarbonInterface + { + return $this->date; + } + + public function bio(): ?string + { + return $this->bio; + } + + public function inviteLink(): ?ChatInviteLink + { + return $this->inviteLink; + } + + public function chat(): Chat + { + return $this->chat; + } + + public function from(): User + { + return $this->from; + } + + public function toArray(): array + { + return array_filter([ + 'user_chat_id' => $this->userChatId, + 'date' => $this->date?->timestamp, + 'bio' => $this->bio, + 'invite_link' => $this->inviteLink?->toArray(), + 'chat' => $this->chat->toArray(), + 'from' => $this->from->toArray(), + ], fn ($value) => $value !== null); + } +} diff --git a/src/Handlers/WebhookHandler.php b/src/Handlers/WebhookHandler.php index 6b6e2005..a0131245 100644 --- a/src/Handlers/WebhookHandler.php +++ b/src/Handlers/WebhookHandler.php @@ -10,6 +10,7 @@ use DefStudio\Telegraph\DTO\CallbackQuery; use DefStudio\Telegraph\DTO\Chat; +use DefStudio\Telegraph\DTO\ChatJoinRequest; use DefStudio\Telegraph\DTO\InlineQuery; use DefStudio\Telegraph\DTO\Message; use DefStudio\Telegraph\DTO\Reaction; @@ -40,6 +41,7 @@ abstract class WebhookHandler protected Message|null $message = null; protected Reaction|null $reaction = null; protected CallbackQuery|null $callbackQuery = null; + protected ChatJoinRequest|null $chatJoinRequest = null; /** * @var Collection|Collection> @@ -294,6 +296,15 @@ public function handle(Request $request, TelegraphBot $bot): void return; } + if ($this->request->has('chat_join_request')) { + /* @phpstan-ignore-next-line */ + $this->chatJoinRequest = ChatJoinRequest::fromArray($this->request->input('chat_join_request')); + $this->setupChat(); + $this->handleChatJoinRequest($this->chatJoinRequest); + + return; + } + if ($this->request->has('callback_query')) { /* @phpstan-ignore-next-line */ @@ -321,6 +332,8 @@ protected function setupChat(): void $telegramChat = $this->message->chat(); } elseif (isset($this->reaction)) { $telegramChat = $this->reaction->chat(); + } elseif (isset($this->chatJoinRequest)) { + $telegramChat = $this->chatJoinRequest->chat(); } else { $telegramChat = $this->callbackQuery?->message()?->chat(); } @@ -410,4 +423,9 @@ protected function getChatName(Chat $chat): string ->append("[", $chat->type(), ']') ->append(" ", $chat->title()); } + + protected function handleChatJoinRequest(ChatJoinRequest $chatJoinRequest): void + { + // .. do nothing + } } From 99183bd278aa9528aa557bdb7be68381fa04ff8d Mon Sep 17 00:00:00 2001 From: Andrzej Nowicki Date: Wed, 30 Oct 2024 21:46:19 +0100 Subject: [PATCH 2/4] add tests for ChatJoinRequest case --- src/Facades/Telegraph.php | 1 + src/Support/Testing/Fakes/TelegraphFake.php | 7 +++ tests/Pest.php | 42 +++++++++++++++++ tests/Support/TestWebhookHandler.php | 6 +++ tests/Unit/DTO/ChatInviteLinkTest.php | 37 +++++++++++++++ tests/Unit/DTO/ChatJoinRequestTest.php | 50 +++++++++++++++++++++ tests/Unit/Handlers/WebhookHandlerTest.php | 9 ++++ 7 files changed, 152 insertions(+) create mode 100644 tests/Unit/DTO/ChatInviteLinkTest.php create mode 100644 tests/Unit/DTO/ChatJoinRequestTest.php diff --git a/src/Facades/Telegraph.php b/src/Facades/Telegraph.php index f936dbea..6972c581 100644 --- a/src/Facades/Telegraph.php +++ b/src/Facades/Telegraph.php @@ -96,6 +96,7 @@ * @method static void assertRepliedWebhook(string $message) * @method static void assertRepliedWebhookIsAlert() * @method static void assertStoredFile(string $fileId) + * @method static void assertChatJoinRequestApproved(string $userId) * * @see \DefStudio\Telegraph\Telegraph */ diff --git a/src/Support/Testing/Fakes/TelegraphFake.php b/src/Support/Testing/Fakes/TelegraphFake.php index 62c3b821..727eeacb 100644 --- a/src/Support/Testing/Fakes/TelegraphFake.php +++ b/src/Support/Testing/Fakes/TelegraphFake.php @@ -170,4 +170,11 @@ public function assertRepliedWebhookIsAlert(): void 'show_alert' => true, ]); } + + public function assertChatJoinRequestApproved(int $userId): void + { + $this->assertSentData(Telegraph::ENDPOINT_APPROVE_CHAT_JOIN_REQUEST, [ + 'user_id' => $userId, + ], false); + } } diff --git a/tests/Pest.php b/tests/Pest.php index 5f7822db..dd8c6af5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -214,6 +214,48 @@ function webhook_inline_query($handler = TestWebhookHandler::class): Request ]); } +function webhook_chat_join_request($handler = TestWebhookHandler::class, int $chat_id = -123456789, int $user_id = 1): Request +{ + register_webhook_handler($handler); + + return Request::create('', 'POST', [ + 'chat_join_request' => [ + 'user_chat_id' => $user_id, + 'bio' => 'bio', + 'date' => now()->timestamp, + 'invite_link' => [ + 'invite_link' => 'https:/t.me/+EEEEEEE...', + 'creator' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'aa', + 'last_name' => 'bb', + 'username' => 'cc', + 'language_code' => 'dd', + 'is_premium' => false, + ], + 'creates_join_request' => true, + 'is_primary' => false, + 'is_revoked' => false, + ], + 'chat' => [ + 'id' => $chat_id, + 'type' => 'a', + 'title' => 'b', + ], + 'from' => [ + 'id' => $user_id, + 'is_bot' => true, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + ], + ]); +} + expect()->extend('toMatchTelegramSnapshot', function () { /** @var Closure(Telegraph): Telegraph $configurationClosure */ diff --git a/tests/Support/TestWebhookHandler.php b/tests/Support/TestWebhookHandler.php index a9e9559b..b5b0010e 100644 --- a/tests/Support/TestWebhookHandler.php +++ b/tests/Support/TestWebhookHandler.php @@ -6,6 +6,7 @@ namespace DefStudio\Telegraph\Tests\Support; +use DefStudio\Telegraph\DTO\ChatJoinRequest; use DefStudio\Telegraph\DTO\InlineQuery; use DefStudio\Telegraph\DTO\InlineQueryResultGif; use DefStudio\Telegraph\DTO\User; @@ -142,6 +143,11 @@ protected function handleChatMemberLeft(User $member): void $this->chat->html("{$member->firstName()} just left")->send(); } + protected function handleChatJoinRequest(ChatJoinRequest $chatJoinRequest): void + { + $this->chat->approveJoinRequest($chatJoinRequest->userChatId())->send(); + } + protected function handleChatReaction(Collection $newReactions, Collection $oldReactions): void { $this->chat->html(implode(':', [ diff --git a/tests/Unit/DTO/ChatInviteLinkTest.php b/tests/Unit/DTO/ChatInviteLinkTest.php new file mode 100644 index 00000000..bc95fe60 --- /dev/null +++ b/tests/Unit/DTO/ChatInviteLinkTest.php @@ -0,0 +1,37 @@ + 'https:/t.me/+EEEEEEE...', + 'creator' => [ + 'id' => 1, + 'is_bot' => true, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'name' => 'name', + 'expire_date' => now()->timestamp, + 'member_limit' => 1, + 'pending_join_requests_count' => 2, + 'subscription_period' => 3, + 'subscription_price' => 4, + 'creates_join_request' => true, + 'is_primary' => false, + 'is_revoked' => false, + ]); + + $array = $dto->toArray(); + + $reflection = new ReflectionClass($dto); + foreach ($reflection->getProperties() as $property) { + expect($array)->toHaveKey(Str::of($property->name)->snake()); + } +}); diff --git a/tests/Unit/DTO/ChatJoinRequestTest.php b/tests/Unit/DTO/ChatJoinRequestTest.php new file mode 100644 index 00000000..39ab6f0d --- /dev/null +++ b/tests/Unit/DTO/ChatJoinRequestTest.php @@ -0,0 +1,50 @@ + 2, + 'bio' => 'bio', + 'date' => now()->timestamp, + 'invite_link' => [ + 'invite_link' => 'https:/t.me/+EEEEEEE...', + 'creator' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'aa', + 'last_name' => 'bb', + 'username' => 'cc', + 'language_code' => 'dd', + 'is_premium' => false, + ], + 'creates_join_request' => true, + 'is_primary' => false, + 'is_revoked' => false, + ], + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'from' => [ + 'id' => 2, + 'is_bot' => true, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + ]); + + $array = $dto->toArray(); + + $reflection = new ReflectionClass($dto); + foreach ($reflection->getProperties() as $property) { + expect($array)->toHaveKey(Str::of($property->name)->snake()); + } +}); diff --git a/tests/Unit/Handlers/WebhookHandlerTest.php b/tests/Unit/Handlers/WebhookHandlerTest.php index 69ad8f6c..f61b62ba 100644 --- a/tests/Unit/Handlers/WebhookHandlerTest.php +++ b/tests/Unit/Handlers/WebhookHandlerTest.php @@ -410,6 +410,15 @@ Facade::assertSent("Bob just left"); }); +it('can handle a chat join request', function () { + $bot = bot(); + Facade::fake(); + + app(TestWebhookHandler::class)->handle(webhook_chat_join_request(user_id: 2), $bot); + + Facade::assertChatJoinRequestApproved(2); +}); + it('can handle a message reaction', function () { Config::set('telegraph.security.allow_messages_from_unknown_chats', true); From 9615f531f836cab6c15bd72f2b600b026f6f4ca5 Mon Sep 17 00:00:00 2001 From: Andrzejka Nowicki Date: Wed, 20 Nov 2024 14:30:58 +0100 Subject: [PATCH 3/4] fixes --- src/DTO/ChatInviteLink.php | 1 + src/DTO/ChatJoinRequest.php | 14 +++++++------- src/Handlers/WebhookHandler.php | 1 + 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/DTO/ChatInviteLink.php b/src/DTO/ChatInviteLink.php index 0f88c75b..752d7c6d 100644 --- a/src/DTO/ChatInviteLink.php +++ b/src/DTO/ChatInviteLink.php @@ -50,6 +50,7 @@ public static function fromArray(array $data): ChatInviteLink $invite->inviteLink = $data['invite_link']; + /* @phpstan-ignore-next-line */ $invite->creator = User::fromArray($data['creator']); $invite->createsJoinRequest = $data['creates_join_request']; diff --git a/src/DTO/ChatJoinRequest.php b/src/DTO/ChatJoinRequest.php index daef68b6..072b98f2 100644 --- a/src/DTO/ChatJoinRequest.php +++ b/src/DTO/ChatJoinRequest.php @@ -12,7 +12,7 @@ class ChatJoinRequest implements Arrayable { private int $userChatId; - private ?CarbonInterface $date = null; + private CarbonInterface $date; private ?string $bio = null; private ?ChatInviteLink $inviteLink = null; private Chat $chat; @@ -38,10 +38,8 @@ public static function fromArray(array $data): ChatJoinRequest $request->userChatId = $data['user_chat_id']; - if (isset($data['date'])) { - /* @phpstan-ignore-next-line */ - $request->date = Carbon::createFromTimestamp($data['date']); - } + /* @phpstan-ignore-next-line */ + $request->date = Carbon::createFromTimestamp($data['date']); if (isset($data['bio'])) { $request->bio = $data['bio']; @@ -52,8 +50,10 @@ public static function fromArray(array $data): ChatJoinRequest $request->inviteLink = ChatInviteLink::fromArray($data['invite_link']); } + /* @phpstan-ignore-next-line */ $request->chat = Chat::fromArray($data['chat']); + /* @phpstan-ignore-next-line */ $request->from = User::fromArray($data['from']); return $request; @@ -64,7 +64,7 @@ public function userChatId(): int return $this->userChatId; } - public function date(): ?CarbonInterface + public function date(): CarbonInterface { return $this->date; } @@ -93,7 +93,7 @@ public function toArray(): array { return array_filter([ 'user_chat_id' => $this->userChatId, - 'date' => $this->date?->timestamp, + 'date' => $this->date->timestamp, 'bio' => $this->bio, 'invite_link' => $this->inviteLink?->toArray(), 'chat' => $this->chat->toArray(), diff --git a/src/Handlers/WebhookHandler.php b/src/Handlers/WebhookHandler.php index a0131245..299ae346 100644 --- a/src/Handlers/WebhookHandler.php +++ b/src/Handlers/WebhookHandler.php @@ -300,6 +300,7 @@ public function handle(Request $request, TelegraphBot $bot): void /* @phpstan-ignore-next-line */ $this->chatJoinRequest = ChatJoinRequest::fromArray($this->request->input('chat_join_request')); $this->setupChat(); + /* @phpstan-ignore-next-line */ $this->handleChatJoinRequest($this->chatJoinRequest); return; From b2c00d5dac1d04b3c2f89726e342bebc757489c5 Mon Sep 17 00:00:00 2001 From: Andrzejka Nowicki Date: Wed, 20 Nov 2024 16:44:49 +0100 Subject: [PATCH 4/4] add documentation --- docs/12.features/9.dto.md | 27 +++++++++++++++++++++ docs/15.webhooks/4.webhook-request-types.md | 24 +++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/12.features/9.dto.md b/docs/12.features/9.dto.md index 94e45e51..513bf41a 100644 --- a/docs/12.features/9.dto.md +++ b/docs/12.features/9.dto.md @@ -233,3 +233,30 @@ This is a DTO for outgoing data, wraps info about the Document result returned t ## `InlineQueryResultLocation` This is a DTO for outgoing data, wraps info about the Location result returned to the user + +## `ChatInviteLink` + +represents an invite link for a chat. + +- `->inviteLink()` the invite link. If the link was created by another chat administrator, then the second part of the link will be replaced with “…” +- `->creator()` an instance of [`User`](#user) represents a creator of the link +- `->createsJoinRequest()` *true*, if users joining the chat via the link need to be approved by chat administrators +- `->isPrimary()` *true*, if the link is primary +- `->isRevoked()` *true*, if the link is revoked +- `->name()` (optional) invite link name +- `->expireDate()` (optional) point in time (Unix timestamp) when the link will expire or has been expired +- `->memberLimit()` (optional) The maximum number of users that can be members of the chat simultaneously after joining the chat via this invite link; 1-99999 +- `->pendingJoinRequestsCount()` (optional) number of pending join requests created using this link +- `->subscriptionPeriod()` (optional) the number of seconds the subscription will be active for before the next payment +- `->subscriptionPrice()` (optional) the amount of Telegram Stars a user must pay initially and after each subsequent subscription period to be a member of the chat using the link + +## `ChatJoinRequest` + +represents a join request sent to a chat. + +- `->chat()` an instance of [`Chat`](#chat) to which the request was sent +- `->from()` an instance of [`User`](#user) that sent the join request +- `->userChatId()` identifier of a private chat with the user who sent the join request. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. But it has at most 52 significant bits, so a 64-bit integer or double-precision float type are safe for storing this identifier. The bot can use this identifier for 5 minutes to send messages until the join request is processed, assuming no other administrator contacted the user. +- `->date()` date the request was sent in Unix time +- `->bio()` (optional) bio of the user +- `->inviteLink()` (optional) an instance of [`ChatInviteLink`](#chat-invite-link) that was used by the user to send the join request \ No newline at end of file diff --git a/docs/15.webhooks/4.webhook-request-types.md b/docs/15.webhooks/4.webhook-request-types.md index 267a3949..82297423 100644 --- a/docs/15.webhooks/4.webhook-request-types.md +++ b/docs/15.webhooks/4.webhook-request-types.md @@ -228,7 +228,23 @@ Different kind of result can be sent through the handler: ## Member activities -Telegraph bots can listen for members join/leave activity in chats where they are registered and handle them by overriding `handleChatMemberJoined` and `handleChatMemberLeaved` methods: +Telegraph bots can listen for members join/leave activity in chats where they are registered and handle them by overriding `handleChatJoinRequest`, `handleChatMemberJoined` and `handleChatMemberLeaved` methods: + +### Member has sent a request to join + +```php +class CustomWebhookHandler extends WebhookHandler +{ + protected function handleChatJoinRequest(ChatJoinRequest $chatJoinRequest): void + { + if (someCondition()) { + $this->chat->approveJoinRequest($chatJoinRequest->from()->id()); + } else { + $this->chat->declineJoinRequest($chatJoinRequest->from()->id()); + } + } +} +``` ### Member joined @@ -253,3 +269,9 @@ class CustomWebhookHandler extends WebhookHandler } } ``` + +Used DTOs: + +- User ([`DefStudio\Telegraph\DTO\User`](../12.features/9.dto.md#user)) +- Chat ([`DefStudio\Telegraph\DTO\Chat`](../12.features/9.dto.md#chat)) +- ChatJoinRequest ([`DefStudio\Telegraph\DTO\ChatJoinRequest`](../12.features/9.dto.md#chatjoinrequest))