Added PhotoAttachment

This commit is contained in:
Alex
2025-07-19 15:40:42 +03:00
parent b9c04bed33
commit 4661edffdf
11 changed files with 420 additions and 14 deletions
+73
View File
@@ -6,18 +6,23 @@ namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Enums\MessageFormat;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use BushlanovDev\MaxMessengerBot\Models\Message;
use BushlanovDev\MaxMessengerBot\Models\MessageLink;
use BushlanovDev\MaxMessengerBot\Models\Result;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use InvalidArgumentException;
use LogicException;
use ReflectionException;
use RuntimeException;
/**
* The main entry point for interacting with the Max Bot API.
@@ -37,6 +42,7 @@ class Api
private const string ACTION_ME = '/me';
private const string ACTION_SUBSCRIPTIONS = '/subscriptions';
private const string ACTION_MESSAGES = '/messages';
private const string ACTION_UPLOADS = '/uploads';
private readonly ClientApiInterface $client;
@@ -204,4 +210,71 @@ class Api
return $this->modelFactory->createMessage($response['message']);
}
/**
* Returns the URL for the subsequent file upload.
*
* @param UploadType $type Uploaded file type.
*
* @return UploadEndpoint Endpoint you should upload to your binaries.
* @throws ReflectionException
*/
public function getUploadUrl(UploadType $type): UploadEndpoint
{
return $this->modelFactory->createUploadEndpoint(
$this->client->request(
self::METHOD_POST,
self::ACTION_UPLOADS,
['type' => $type->value],
)
);
}
/**
* A simplified method for uploading a file and getting the resulting attachment object.
*
* @param UploadType $type Uploaded file type.
* @param string $filePath Path to the file on the local disk.
*
* @return AbstractAttachmentRequest
* @throws ReflectionException
* @throws SerializationException
* @throws InvalidArgumentException
* @throws RuntimeException
* @throws LogicException
* @throws NetworkException
* @throws ClientApiException
*/
public function uploadAttachment(UploadType $type, string $filePath): AbstractAttachmentRequest
{
if (!file_exists($filePath) || !is_readable($filePath)) {
throw new InvalidArgumentException("File not found or not readable: $filePath");
}
$fileHandle = fopen($filePath, 'r');
if ($fileHandle === false) {
throw new RuntimeException("Could not open file for reading: $filePath");
}
$uploadEndpoint = $this->getUploadUrl($type);
$uploadResult = $this->client->upload(
$uploadEndpoint->url,
$fileHandle,
basename($filePath),
);
fclose($fileHandle);
if (!isset($uploadResult['token'])) {
throw new SerializationException('Could not find "token" in upload server response.');
}
return match ($type) {
UploadType::Image => PhotoAttachmentRequest::fromToken($uploadResult['token']),
default => throw new LogicException(
"Attachment creation for type '$type->value' is not yet implemented."
),
};
}
}
+42
View File
@@ -100,6 +100,48 @@ final class Client implements ClientApiInterface
}
}
/**
* @inheritDoc
*/
public function upload(string $uri, mixed $fileContents, string $fileName): array
{
$boundary = '--------------------------' . microtime(true);
$bodyStream = $this->streamFactory->createStream();
$bodyStream->write("--$boundary\r\n");
$bodyStream->write("Content-Disposition: form-data; name=\"data\"; filename=\"{$fileName}\"\r\n");
$bodyStream->write("Content-Type: application/octet-stream\r\n\r\n");
if (is_resource($fileContents)) {
$bodyStream->write((string)stream_get_contents($fileContents));
} else {
$bodyStream->write((string)$fileContents);
}
$bodyStream->write("\r\n");
$bodyStream->write("--$boundary--\r\n");
$request = $this->requestFactory
->createRequest('POST', $uri)
->withHeader('Content-Type', 'multipart/form-data; boundary=' . $boundary)
->withBody($bodyStream);
try {
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
}
$this->handleErrorResponse($response);
$responseBody = (string)$response->getBody();
try {
return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new SerializationException('Failed to decode upload server response JSON.', 0, $e);
}
}
/**
* Checks the response for an error status code and throws a corresponding typed exception.
*
+14
View File
@@ -24,4 +24,18 @@ interface ClientApiInterface
* @throws SerializationException for JSON encoding/decoding failures.
*/
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array;
/**
* Performs a file download at the specified URL.
*
* @param string $uri URL received from the download API.
* @param resource|string $fileContents File content (stream resource or string).
* @param string $fileName The name of the file that will be sent to the server.
*
* @return array<string, mixed>
* @throws ClientApiException
* @throws NetworkException
* @throws SerializationException
*/
public function upload(string $uri, mixed $fileContents, string $fileName): array;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Enums;
enum UploadType: string
{
case Image = 'image';
case Video = 'video';
case Audio = 'audio';
case File = 'file';
}
+14
View File
@@ -8,6 +8,7 @@ use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use BushlanovDev\MaxMessengerBot\Models\Message;
use BushlanovDev\MaxMessengerBot\Models\Result;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use ReflectionException;
/**
@@ -81,4 +82,17 @@ class ModelFactory
{
return Message::fromArray($data);
}
/**
* Endpoint you should upload to your binaries.
*
* @param array<string, mixed> $data
*
* @return UploadEndpoint
* @throws ReflectionException
*/
public function createUploadEndpoint(array $data): UploadEndpoint
{
return UploadEndpoint::fromArray($data);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
use InvalidArgumentException;
/**
* Request to attach image. All fields are mutually exclusive.
*/
final readonly class PhotoAttachmentPayload extends AbstractAttachmentPayload
{
/**
* @param string|null $url Any external image URL you want to attach.
* @param string|null $token Token of any existing attachment.
* @param PhotoToken[]|null $photos Tokens were obtained after uploading images.
*/
public function __construct(
public ?string $url = null,
public ?string $token = null,
#[ArrayOf(PhotoToken::class)]
public ?array $photos = null,
) {
if (count(array_filter([$this->url, $this->token, $this->photos])) !== 1) {
throw new InvalidArgumentException(
'Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.'
);
}
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Encoded information of uploaded image
*/
final readonly class PhotoToken extends AbstractModel
{
/**
* @param string $token Encoded information of uploaded image.
*/
public function __construct(
public string $token,
) {
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Requests;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoToken;
/**
* Request to attach some data to message.
*/
final readonly class PhotoAttachmentRequest extends AbstractAttachmentRequest
{
/**
* Creates a request to attach an image by URL.
*
* @param string $url
*
* @return PhotoAttachmentRequest
*/
public static function fromUrl(string $url): self
{
return new self(new PhotoAttachmentPayload(url: $url));
}
/**
* Creates a request to attach an image using the token received after uploading.
*
* @param string $token
*
* @return PhotoAttachmentRequest
*/
public static function fromToken(string $token): self
{
return new self(new PhotoAttachmentPayload(token: $token));
}
/**
* Creates a request to attach an image using the tokens received after uploading.
*
* @param PhotoToken[] $photos
*
* @return PhotoAttachmentRequest
*/
public static function fromPhotos(array $photos): self
{
return new self(new PhotoAttachmentPayload(photos: $photos));
}
/**
* @param PhotoAttachmentPayload $payload Request to attach image.
*/
private function __construct(PhotoAttachmentPayload $payload)
{
parent::__construct(AttachmentType::Image, $payload);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* Endpoint you should upload to your binaries
*/
final readonly class UploadEndpoint extends AbstractModel
{
/**
* @param string $url URL to upload.
* @param string|null $token Video or audio token for send message.
*/
public function __construct(
public string $url,
public ?string $token = null,
) {
}
}
+82
View File
@@ -11,10 +11,14 @@ use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Enums\MessageFormat;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\CallbackButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\InlineKeyboardPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoToken;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\InlineKeyboardAttachmentRequest;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use BushlanovDev\MaxMessengerBot\Models\Message;
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
@@ -22,6 +26,8 @@ use BushlanovDev\MaxMessengerBot\Models\Recipient;
use BushlanovDev\MaxMessengerBot\Models\Result;
use BushlanovDev\MaxMessengerBot\Models\Sender;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
@@ -42,6 +48,10 @@ use ReflectionClass;
#[UsesClass(CallbackButton::class)]
#[UsesClass(InlineKeyboardPayload::class)]
#[UsesClass(InlineKeyboardAttachmentRequest::class)]
#[UsesClass(PhotoToken::class)]
#[UsesClass(PhotoAttachmentRequest::class)]
#[UsesClass(PhotoAttachmentPayload::class)]
#[UsesClass(UploadEndpoint::class)]
final class ApiTest extends TestCase
{
private MockObject&ClientApiInterface $clientMock;
@@ -368,4 +378,76 @@ final class ApiTest extends TestCase
$this->assertSame($expectedMessageObject, $result);
}
#[Test]
public function uploadAttachmentSuccessfullyUploadsImageAndReturnsAttachment(): void
{
$filePath = tempnam(sys_get_temp_dir(), 'test_upload_');
file_put_contents($filePath, 'fake-image-content');
$uploadType = UploadType::Image;
$uploadUrl = 'https://upload.server/gohere';
$uploadToken = 'FINAL_TOKEN_123';
$getUploadUrlResponse = ['url' => $uploadUrl];
$uploadResponse = ['token' => $uploadToken];
$expectedEndpoint = new UploadEndpoint($uploadUrl);
$expectedAttachment = PhotoAttachmentRequest::fromToken($uploadToken);
$this->clientMock
->expects($this->once())
->method('request')
->with('POST', '/uploads', ['type' => $uploadType->value])
->willReturn($getUploadUrlResponse);
$this->modelFactoryMock
->expects($this->once())
->method('createUploadEndpoint')
->with($getUploadUrlResponse)
->willReturn($expectedEndpoint);
$this->clientMock
->expects($this->once())
->method('upload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
$result = $this->api->uploadAttachment($uploadType, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
#[Test]
public function uploadAttachmentForMultiplePhotosReturnsCorrectAttachment(): void
{
$filePath = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($filePath, 'content');
$getUploadUrlResponse = ['url' => 'http://upload.server'];
$expectedEndpoint = new UploadEndpoint('http://upload.server');
$uploadResponse = ['token' => 'token'];
$expectedAttachment = PhotoAttachmentRequest::fromToken('token');
$this->clientMock->method('request')->willReturn($getUploadUrlResponse);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn($expectedEndpoint);
$this->clientMock->method('upload')->willReturn($uploadResponse);
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
#[Test]
public function uploadAttachmentThrowsExceptionForNonExistentFile(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/File not found or not readable/');
$this->api->uploadAttachment(UploadType::Image, '/path/to/non/existent/file.jpg');
}
}
+48 -14
View File
@@ -13,6 +13,7 @@ use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException;
use BushlanovDev\MaxMessengerBot\Exceptions\RateLimitExceededException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException;
use GuzzleHttp\Psr7\HttpFactory;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -37,7 +38,7 @@ final class ClientTest extends TestCase
private MockObject&ClientInterface $httpClientMock;
private MockObject&RequestFactoryInterface $requestFactoryMock;
private MockObject&StreamFactoryInterface $streamFactoryMock;
private StreamFactoryInterface $streamFactory;
private MockObject&RequestInterface $requestMock;
private MockObject&ResponseInterface $responseMock;
private MockObject&StreamInterface $streamMock;
@@ -56,7 +57,7 @@ final class ClientTest extends TestCase
// Create mocks for all PSR interfaces
$this->httpClientMock = $this->createMock(ClientInterface::class);
$this->requestFactoryMock = $this->createMock(RequestFactoryInterface::class);
$this->streamFactoryMock = $this->createMock(StreamFactoryInterface::class);
$this->streamFactory = new HttpFactory();
$this->requestMock = $this->createMock(RequestInterface::class);
$this->responseMock = $this->createMock(ResponseInterface::class);
$this->streamMock = $this->createMock(StreamInterface::class);
@@ -65,15 +66,13 @@ final class ClientTest extends TestCase
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->responseMock->method('getBody')->willReturn($this->streamMock);
$this->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
$this->requestMock->method('withBody')->willReturn($this->requestMock);
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
// Instantiate the System Under Test (SUT)
$this->client = new Client(
self::FAKE_TOKEN,
$this->httpClientMock,
$this->requestFactoryMock,
$this->streamFactoryMock,
$this->streamFactory,
self::API_BASE_URL,
self::API_VERSION,
);
@@ -85,7 +84,7 @@ final class ClientTest extends TestCase
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Access token cannot be empty.');
new Client('', $this->httpClientMock, $this->requestFactoryMock, $this->streamFactoryMock, '', '');
new Client('', $this->httpClientMock, $this->requestFactoryMock, $this->streamFactory, '', '');
}
#[Test]
@@ -134,7 +133,6 @@ final class ClientTest extends TestCase
],
];
$responsePayload = ['success' => true];
$expectedUrl = self::API_BASE_URL . $uri . '?' . http_build_query([
'access_token' => self::FAKE_TOKEN,
'v' => self::API_VERSION
@@ -146,16 +144,13 @@ final class ClientTest extends TestCase
->with('POST', $expectedUrl)
->willReturn($this->requestMock);
$this->streamFactoryMock
->expects($this->once())
->method('createStream')
->with(json_encode($requestBody))
->willReturn($this->streamMock);
$this->requestMock
->expects($this->once())
->method('withBody')
->with($this->streamMock)
->with($this->callback(function (StreamInterface $stream) use ($requestBody) {
$this->assertSame(json_encode($requestBody), $stream->getContents());
return true;
}))
->willReturn($this->requestMock);
$this->requestMock
@@ -273,4 +268,43 @@ final class ClientTest extends TestCase
throw $e; // Re-throw for PHPUnit to catch the expected exception type
}
}
#[Test]
public function uploadMethodSendsCorrectMultipartRequest(): void
{
$uploadUrl = 'https://upload.server/path';
$fileContents = 'fake-image-binary-data';
$fileName = 'test.jpg';
$responsePayload = ['token' => 'upload_successful_token'];
$this->requestMock
->expects($this->once())
->method('withBody')
->with($this->callback(function (StreamInterface $stream) use ($fileContents, $fileName) {
$stream->rewind();
$body = $stream->getContents();
$this->assertStringContainsString('Content-Disposition: form-data; name="data"; filename="' . $fileName . '"', $body);
$this->assertStringContainsString($fileContents, $body);
return true;
}))
->willReturn($this->requestMock);
$this->requestMock
->expects($this->once())
->method('withHeader')
->with($this->stringStartsWith('Content-Type'), $this->stringStartsWith('multipart/form-data'))
->willReturn($this->requestMock);
$this->requestFactoryMock
->expects($this->once())
->method('createRequest')
->with('POST', $uploadUrl)
->willReturn($this->requestMock);
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
$result = $this->client->upload($uploadUrl, $fileContents, $fileName);
$this->assertSame($responsePayload, $result);
}
}