Add Telegram publishing (backend foundation)
Register Telegram as a platform: Platform/ContentType enum cases, a platforms.telegram config block (shared bot token via env), TelegramPublisher (sendMessage / sendPhoto|Video|Document / sendMediaGroup over the Bot API, HTML parse mode, 4096 limit with long text split off a 1024 caption), wired into the publisher dispatch. Add a Telegram ContentSanitizer branch (Telegram-allowed HTML + ampersand escaping), MediaOptimizer/profile-url/factory support, and the TelegramPublishException. Tests cover text, single media, album, long-text split, overflow, API errors, private-channel URLs, and sanitization.
This commit is contained in:
parent
986e2661f9
commit
9634e88e5d
12 changed files with 526 additions and 1 deletions
|
|
@ -50,6 +50,9 @@ enum ContentType: string
|
|||
// Mastodon
|
||||
case MastodonPost = 'mastodon_post';
|
||||
|
||||
// Telegram
|
||||
case TelegramPost = 'telegram_post';
|
||||
|
||||
/**
|
||||
* AI generation format for an Instagram carousel. Not a content type —
|
||||
* carousel posts are persisted as InstagramFeed.
|
||||
|
|
@ -77,6 +80,7 @@ public function label(): string
|
|||
self::PinterestCarousel => 'Carousel',
|
||||
self::BlueskyPost => 'Post',
|
||||
self::MastodonPost => 'Post',
|
||||
self::TelegramPost => 'Post',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +103,7 @@ public function platform(): SocialPlatform
|
|||
self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest,
|
||||
self::BlueskyPost => SocialPlatform::Bluesky,
|
||||
self::MastodonPost => SocialPlatform::Mastodon,
|
||||
self::TelegramPost => SocialPlatform::Telegram,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +172,7 @@ public function maxMediaCount(): int
|
|||
self::PinterestCarousel => 5,
|
||||
self::BlueskyPost => 4,
|
||||
self::MastodonPost => 4,
|
||||
self::TelegramPost => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +192,7 @@ public function supportsVideo(): bool
|
|||
self::PinterestPin, self::PinterestCarousel => false,
|
||||
self::BlueskyPost => true,
|
||||
self::MastodonPost => true,
|
||||
self::TelegramPost => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +229,7 @@ public function requiresMedia(): bool
|
|||
self::ThreadsPost => false,
|
||||
self::BlueskyPost => false,
|
||||
self::MastodonPost => false,
|
||||
self::TelegramPost => false,
|
||||
self::FacebookPost => false,
|
||||
self::InstagramFeed => false,
|
||||
default => true,
|
||||
|
|
@ -304,6 +312,7 @@ public static function defaultFor(SocialPlatform $platform): self
|
|||
SocialPlatform::Pinterest => self::PinterestPin,
|
||||
SocialPlatform::Bluesky => self::BlueskyPost,
|
||||
SocialPlatform::Mastodon => self::MastodonPost,
|
||||
SocialPlatform::Telegram => self::TelegramPost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ enum Platform: string
|
|||
case Pinterest = 'pinterest';
|
||||
case Bluesky = 'bluesky';
|
||||
case Mastodon = 'mastodon';
|
||||
case Telegram = 'telegram';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -36,6 +37,7 @@ public function label(): string
|
|||
self::Pinterest => 'Pinterest',
|
||||
self::Bluesky => 'Bluesky',
|
||||
self::Mastodon => 'Mastodon',
|
||||
self::Telegram => 'Telegram',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ public function color(): string
|
|||
self::Pinterest => '#E60023',
|
||||
self::Bluesky => '#0085FF',
|
||||
self::Mastodon => '#6364FF',
|
||||
self::Telegram => '#26A5E4',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +72,7 @@ public function allowedMediaTypes(): array
|
|||
self::Pinterest => [MediaType::Image, MediaType::Video],
|
||||
self::Bluesky => [MediaType::Image, MediaType::Video],
|
||||
self::Mastodon => [MediaType::Image, MediaType::Video],
|
||||
self::Telegram => [MediaType::Image, MediaType::Video],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +89,7 @@ public function maxImages(): int
|
|||
self::Pinterest => 5,
|
||||
self::Bluesky => 4,
|
||||
self::Mastodon => 4,
|
||||
self::Telegram => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +112,8 @@ public function maxImages(): int
|
|||
* - Pinterest pin description: 800 (title is 100, not modeled here)
|
||||
* - Bluesky: 300 graphemes
|
||||
* - Mastodon: 500 default; instances may be higher (we stay conservative)
|
||||
* - Telegram: 4096 for a text message (media captions are capped at 1024,
|
||||
* handled in the publisher by sending long text as its own message)
|
||||
*/
|
||||
public function maxContentLength(): int
|
||||
{
|
||||
|
|
@ -121,6 +128,7 @@ public function maxContentLength(): int
|
|||
self::Pinterest => 800,
|
||||
self::Bluesky => 300,
|
||||
self::Mastodon => 500,
|
||||
self::Telegram => 4096,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +170,8 @@ public function recommendedAiContentLength(): int
|
|||
// YouTube Shorts — fits within the 100-char title (with " #Shorts"
|
||||
// suffix taking 8 chars) so the same string works as title + desc
|
||||
self::YouTube => 80,
|
||||
// Telegram channel posts — short announcements read best
|
||||
self::Telegram => 400,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +193,7 @@ public function requiredPublishScopes(): array
|
|||
self::Pinterest => ['pins:write'],
|
||||
self::Bluesky => [],
|
||||
self::Mastodon => ['write:statuses'],
|
||||
self::Telegram => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +210,7 @@ public function supportsTextOnly(): bool
|
|||
self::Pinterest => false,
|
||||
self::Bluesky => true,
|
||||
self::Mastodon => true,
|
||||
self::Telegram => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
68
app/Exceptions/Social/TelegramPublishException.php
Normal file
68
app/Exceptions/Social/TelegramPublishException.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class TelegramPublishException extends SocialPublishException
|
||||
{
|
||||
public static function fromApiResponse(mixed $response): static
|
||||
{
|
||||
/** @var Response $response */
|
||||
$status = $response->status();
|
||||
$rawResponse = $response->body();
|
||||
$description = (string) data_get($response->json(), 'description', 'An unknown Telegram error occurred.');
|
||||
|
||||
// 403: the bot was removed or isn't an admin of the channel anymore.
|
||||
if ($status === 403) {
|
||||
return new static(
|
||||
userMessage: 'The bot is not an admin of this channel. Re-add it as an administrator and try again.',
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
// 401: the configured bot token is invalid (operator-level misconfiguration).
|
||||
if ($status === 401) {
|
||||
return new static(
|
||||
userMessage: 'Telegram rejected the bot token. Check the TELEGRAM_BOT_TOKEN configuration.',
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 429) {
|
||||
return new static(
|
||||
userMessage: 'Telegram rate limit reached. Please try again shortly.',
|
||||
category: ErrorCategory::RateLimit,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if ($status >= 500) {
|
||||
return new static(
|
||||
userMessage: 'Telegram is temporarily unavailable. Please try again later.',
|
||||
category: ErrorCategory::ServerError,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
return new static(
|
||||
userMessage: $description,
|
||||
category: ErrorCategory::ContentPolicy,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public function platform(): string
|
||||
{
|
||||
return 'telegram';
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
use App\Services\Social\LinkedInPublisher;
|
||||
use App\Services\Social\MastodonPublisher;
|
||||
use App\Services\Social\PinterestPublisher;
|
||||
use App\Services\Social\TelegramPublisher;
|
||||
use App\Services\Social\ThreadsPublisher;
|
||||
use App\Services\Social\TikTokPublisher;
|
||||
use App\Services\Social\XPublisher;
|
||||
|
|
@ -221,7 +222,7 @@ private function broadcastStatus(): void
|
|||
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
|
||||
}
|
||||
|
||||
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher
|
||||
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher
|
||||
{
|
||||
return match ($this->postPlatform->platform) {
|
||||
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
|
||||
|
|
@ -235,6 +236,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis
|
|||
SocialPlatform::Pinterest => app(PinterestPublisher::class),
|
||||
SocialPlatform::Bluesky => app(BlueskyPublisher::class),
|
||||
SocialPlatform::Mastodon => app(MastodonPublisher::class),
|
||||
SocialPlatform::Telegram => app(TelegramPublisher::class),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ protected function profileUrl(): Attribute
|
|||
SocialPlatform::Mastodon => ($username && data_get($this->meta, 'instance'))
|
||||
? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}"
|
||||
: null,
|
||||
SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null,
|
||||
default => null,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -196,6 +196,12 @@ private function getImageConfig(Platform $platform): array
|
|||
'format' => 'image/jpeg',
|
||||
'quality' => 100,
|
||||
],
|
||||
Platform::Telegram => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 10 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 100,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,36 @@ public function sanitize(string $content, Platform $platform): string
|
|||
return match ($platform) {
|
||||
Platform::LinkedIn, Platform::LinkedInPage => $this->convertBoldAndStrip($content),
|
||||
Platform::Mastodon => $this->stripUnsafeHtml($content),
|
||||
Platform::Telegram => $this->toTelegramHtml($content),
|
||||
default => $this->stripHtml($content),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram's `parse_mode=HTML` accepts a small tag allowlist and rejects
|
||||
* the rest; bare ampersands must be escaped or the parser errors.
|
||||
*/
|
||||
private function toTelegramHtml(string $content): string
|
||||
{
|
||||
// Block elements → newlines (Telegram HTML has no <p>/<br>/<li>).
|
||||
$content = preg_replace('/<p[^>]*>/i', '', $content);
|
||||
$content = str_replace('</p>', "\n", $content);
|
||||
$content = preg_replace('/<br\s*\/?>/i', "\n", $content);
|
||||
$content = preg_replace('/<li[^>]*>/i', '- ', $content);
|
||||
$content = str_replace('</li>', "\n", $content);
|
||||
|
||||
// Normalize to Telegram's tag names, then keep only its allowlist.
|
||||
$content = preg_replace(['/<(\/?)strong>/i', '/<(\/?)em>/i'], ['<$1b>', '<$1i>'], $content);
|
||||
$content = strip_tags($content, ['b', 'i', 'u', 's', 'a', 'code', 'pre']);
|
||||
|
||||
// Escape bare ampersands while leaving existing entities intact.
|
||||
$content = preg_replace('/&(?!(?:amp|lt|gt|quot|#\d+);)/', '&', $content);
|
||||
|
||||
$content = preg_replace("/\n{3,}/", "\n\n", $content);
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function stripHtml(string $content): string
|
||||
{
|
||||
// Convert <p> tags to newlines
|
||||
|
|
|
|||
186
app/Services/Social/TelegramPublisher.php
Normal file
186
app/Services/Social/TelegramPublisher.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\DataTransferObjects\MediaItem;
|
||||
use App\Exceptions\Social\TelegramPublishException;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TelegramPublisher
|
||||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
/**
|
||||
* Telegram caps media captions at 1024 chars (a text-only message allows
|
||||
* 4096). When the post is longer than a caption, the media is sent first and
|
||||
* the full text follows as its own message.
|
||||
*/
|
||||
private const CAPTION_LIMIT = 1024;
|
||||
|
||||
private const ALBUM_CHUNK = 10;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
$chatId = (string) data_get($account->meta, 'chat_id');
|
||||
|
||||
$content = $postPlatform->post->content
|
||||
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
|
||||
: '';
|
||||
|
||||
$media = $postPlatform->post->mediaItems->take(self::ALBUM_CHUNK);
|
||||
|
||||
$messageId = $media->isEmpty()
|
||||
? $this->sendText($chatId, $content)
|
||||
: $this->sendWithMedia($chatId, $content, $media);
|
||||
|
||||
return [
|
||||
'id' => (string) $messageId,
|
||||
'url' => $this->buildPostUrl($account, $messageId),
|
||||
];
|
||||
}
|
||||
|
||||
private function sendText(string $chatId, string $text): int
|
||||
{
|
||||
$response = $this->call('sendMessage', [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
]);
|
||||
|
||||
return (int) data_get($response->json(), 'result.message_id');
|
||||
}
|
||||
|
||||
private function sendWithMedia(string $chatId, string $content, Collection $media): int
|
||||
{
|
||||
$fitsCaption = mb_strlen($content) <= self::CAPTION_LIMIT;
|
||||
$caption = $fitsCaption ? $content : '';
|
||||
|
||||
$items = $media->map(fn (MediaItem $item) => $this->telegramMedia($item))->values()->all();
|
||||
|
||||
$messageId = count($items) === 1
|
||||
? $this->sendSingleMedia($chatId, $items[0], $caption)
|
||||
: $this->sendMediaGroup($chatId, $items, $caption);
|
||||
|
||||
// Long text can't ride along as a caption — send it as a follow-up message.
|
||||
if (! $fitsCaption) {
|
||||
$this->sendText($chatId, $content);
|
||||
}
|
||||
|
||||
return $messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, url: string} $item
|
||||
*/
|
||||
private function sendSingleMedia(string $chatId, array $item, string $caption): int
|
||||
{
|
||||
$method = match ($item['type']) {
|
||||
'photo' => 'sendPhoto',
|
||||
'video' => 'sendVideo',
|
||||
default => 'sendDocument',
|
||||
};
|
||||
|
||||
$response = $this->call($method, [
|
||||
'chat_id' => $chatId,
|
||||
$item['type'] => $item['url'],
|
||||
'caption' => $caption,
|
||||
'parse_mode' => 'HTML',
|
||||
]);
|
||||
|
||||
return (int) data_get($response->json(), 'result.message_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{type: string, url: string}> $items
|
||||
*/
|
||||
private function sendMediaGroup(string $chatId, array $items, string $caption): int
|
||||
{
|
||||
$firstMessageId = 0;
|
||||
|
||||
foreach (array_chunk($items, self::ALBUM_CHUNK) as $chunkIndex => $chunk) {
|
||||
$group = [];
|
||||
|
||||
foreach ($chunk as $itemIndex => $item) {
|
||||
$entry = [
|
||||
// Documents can't be mixed into an album; send them as photos/videos only.
|
||||
'type' => $item['type'] === 'document' ? 'document' : $item['type'],
|
||||
'media' => $item['url'],
|
||||
];
|
||||
|
||||
if ($chunkIndex === 0 && $itemIndex === 0 && $caption !== '') {
|
||||
$entry['caption'] = $caption;
|
||||
$entry['parse_mode'] = 'HTML';
|
||||
}
|
||||
|
||||
$group[] = $entry;
|
||||
}
|
||||
|
||||
$response = $this->call('sendMediaGroup', [
|
||||
'chat_id' => $chatId,
|
||||
'media' => json_encode($group),
|
||||
]);
|
||||
|
||||
if ($chunkIndex === 0) {
|
||||
$firstMessageId = (int) data_get($response->json(), 'result.0.message_id');
|
||||
}
|
||||
}
|
||||
|
||||
return $firstMessageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, url: string}
|
||||
*/
|
||||
private function telegramMedia(MediaItem $media): array
|
||||
{
|
||||
$type = match (true) {
|
||||
$media->isImage() => 'photo',
|
||||
$media->isVideo() => 'video',
|
||||
default => 'document',
|
||||
};
|
||||
|
||||
return ['type' => $type, 'url' => $media->url];
|
||||
}
|
||||
|
||||
private function call(string $method, array $payload): Response
|
||||
{
|
||||
$token = (string) config('trypost.platforms.telegram.bot_token');
|
||||
$api = rtrim((string) config('trypost.platforms.telegram.api'), '/');
|
||||
|
||||
$response = $this->socialHttp()->post("{$api}/bot{$token}/{$method}", $payload);
|
||||
|
||||
if ($response->failed() || data_get($response->json(), 'ok') !== true) {
|
||||
$this->handleApiError($response);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function buildPostUrl(SocialAccount $account, int $messageId): string
|
||||
{
|
||||
$username = (string) data_get($account->meta, 'username');
|
||||
|
||||
if ($username !== '') {
|
||||
return "https://t.me/{$username}/{$messageId}";
|
||||
}
|
||||
|
||||
// Private channels: t.me/c/<id without the -100 prefix>/<message_id>.
|
||||
$internalId = preg_replace('/^-100/', '', (string) data_get($account->meta, 'chat_id'));
|
||||
|
||||
return "https://t.me/c/{$internalId}/{$messageId}";
|
||||
}
|
||||
|
||||
private function handleApiError(Response $response): never
|
||||
{
|
||||
throw TelegramPublishException::fromApiResponse($response);
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,15 @@
|
|||
// Default instance used when the account has no `meta.instance` override.
|
||||
'default_instance' => env('MASTODON_DEFAULT_INSTANCE', 'https://mastodon.social'),
|
||||
],
|
||||
'telegram' => [
|
||||
'enabled' => env('TELEGRAM_ENABLED', true),
|
||||
// Single shared bot (BotFather). Users add it as admin to their channel.
|
||||
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
|
||||
'bot_username' => env('TELEGRAM_BOT_USERNAME'),
|
||||
'api' => env('TELEGRAM_API', 'https://api.telegram.org'),
|
||||
// Secret-token header Telegram echoes on every webhook call.
|
||||
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -137,6 +137,23 @@ public function mastodon(): static
|
|||
]);
|
||||
}
|
||||
|
||||
public function telegram(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Telegram,
|
||||
'scopes' => Platform::Telegram->requiredPublishScopes(),
|
||||
'token_expires_at' => null, // the shared bot token never expires
|
||||
'access_token' => '',
|
||||
'refresh_token' => '',
|
||||
'username' => 'mychannel',
|
||||
'meta' => [
|
||||
'chat_id' => '-1001234567890',
|
||||
'username' => 'mychannel',
|
||||
'type' => 'channel',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function disconnected(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
|
|
|||
|
|
@ -76,3 +76,21 @@
|
|||
$result = $sanitizer->sanitize('<p>Check <a href="https://example.com">this</a></p>', Platform::Mastodon);
|
||||
expect($result)->toContain('<a href="https://example.com">this</a>');
|
||||
});
|
||||
|
||||
test('it keeps telegram-allowed html and converts strong/em', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<p>Hello <strong>world</strong> and <em>you</em></p>', Platform::Telegram);
|
||||
expect($result)->toBe('Hello <b>world</b> and <i>you</i>');
|
||||
});
|
||||
|
||||
test('it strips disallowed tags but keeps links for telegram', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<div>see <a href="https://example.com">link</a></div><script>x</script>', Platform::Telegram);
|
||||
expect($result)->toBe('see <a href="https://example.com">link</a>x');
|
||||
});
|
||||
|
||||
test('it escapes bare ampersands for telegram', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('Tom & Jerry & friends', Platform::Telegram);
|
||||
expect($result)->toBe('Tom & Jerry & friends');
|
||||
});
|
||||
|
|
|
|||
171
tests/Feature/Services/Social/TelegramPublisherTest.php
Normal file
171
tests/Feature/Services/Social/TelegramPublisherTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\Social\TelegramPublishException;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\TelegramPublisher;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$this->socialAccount = SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'content' => 'Hello world',
|
||||
]);
|
||||
|
||||
$this->postPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'platform' => Platform::Telegram,
|
||||
'content_type' => ContentType::TelegramPost,
|
||||
]);
|
||||
|
||||
$this->publisher = new TelegramPublisher;
|
||||
});
|
||||
|
||||
function telegramOk(array $result): array
|
||||
{
|
||||
return ['ok' => true, 'result' => $result];
|
||||
}
|
||||
|
||||
test('telegram publisher sends a text-only message', function () {
|
||||
Http::fake([
|
||||
'*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 42]), 200),
|
||||
]);
|
||||
|
||||
$result = $this->publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['id'])->toBe('42');
|
||||
expect($result['url'])->toBe('https://t.me/mychannel/42');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/sendMessage')
|
||||
&& $request['chat_id'] === '-1001234567890'
|
||||
&& $request['text'] === 'Hello world'
|
||||
&& $request['parse_mode'] === 'HTML';
|
||||
});
|
||||
});
|
||||
|
||||
test('telegram publisher sends a single image with caption', function () {
|
||||
$this->post->update([
|
||||
'content' => 'A photo',
|
||||
'media' => [[
|
||||
'id' => 'm1',
|
||||
'path' => 'media/2026-01/pic.jpg',
|
||||
'url' => 'https://cdn.test/pic.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'original_filename' => 'pic.jpg',
|
||||
]],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*/botTESTTOKEN/sendPhoto' => Http::response(telegramOk(['message_id' => 7]), 200),
|
||||
]);
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/sendPhoto')
|
||||
&& str_contains($request['photo'], 'pic.jpg')
|
||||
&& $request['caption'] === 'A photo'
|
||||
&& $request['parse_mode'] === 'HTML';
|
||||
});
|
||||
});
|
||||
|
||||
test('telegram publisher sends multiple media as an album', function () {
|
||||
$this->post->update([
|
||||
'content' => 'Album',
|
||||
'media' => [
|
||||
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://cdn.test/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
|
||||
['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://cdn.test/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'],
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*/botTESTTOKEN/sendMediaGroup' => Http::response(telegramOk([['message_id' => 11], ['message_id' => 12]]), 200),
|
||||
]);
|
||||
|
||||
$result = $this->publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['id'])->toBe('11');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/sendMediaGroup')) {
|
||||
return false;
|
||||
}
|
||||
$media = json_decode($request['media'], true);
|
||||
|
||||
return count($media) === 2
|
||||
&& $media[0]['type'] === 'photo'
|
||||
&& $media[0]['caption'] === 'Album'
|
||||
&& ! isset($media[1]['caption']);
|
||||
});
|
||||
});
|
||||
|
||||
test('telegram publisher sends long text as its own message after media', function () {
|
||||
$longText = str_repeat('x', 1500); // over the 1024 caption limit
|
||||
|
||||
$this->post->update([
|
||||
'content' => $longText,
|
||||
'media' => [[
|
||||
'id' => 'm1', 'path' => 'media/p.jpg', 'url' => 'https://cdn.test/p.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'p.jpg',
|
||||
]],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*/botTESTTOKEN/sendPhoto' => Http::response(telegramOk(['message_id' => 5]), 200),
|
||||
'*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 6]), 200),
|
||||
]);
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// Photo carries no caption (too long); the text follows as a separate message.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/sendPhoto') && $request['caption'] === '');
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/sendMessage') && $request['text'] === $longText);
|
||||
});
|
||||
|
||||
test('telegram publisher rejects content over the 4096 limit', function () {
|
||||
$this->post->update(['content' => str_repeat('x', 4097)]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(Exception::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('telegram publisher throws on a non-ok response', function () {
|
||||
Http::fake([
|
||||
'*/botTESTTOKEN/sendMessage' => Http::response(['ok' => false, 'error_code' => 403, 'description' => 'Forbidden: bot is not a member of the channel chat'], 403),
|
||||
]);
|
||||
|
||||
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(TelegramPublishException::class);
|
||||
});
|
||||
|
||||
test('telegram publisher builds a private-channel url when there is no username', function () {
|
||||
$this->socialAccount->update(['username' => null, 'meta' => ['chat_id' => '-1009876543210', 'type' => 'channel']]);
|
||||
|
||||
Http::fake([
|
||||
'*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 99]), 200),
|
||||
]);
|
||||
|
||||
$result = $this->publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['url'])->toBe('https://t.me/c/9876543210/99');
|
||||
});
|
||||
Loading…
Reference in a new issue