Merge pull request #95 from trypostit/feat/telegram-channel

Add Telegram publishing channel
This commit is contained in:
Paulo Castellano 2026-06-14 14:46:33 -03:00 committed by GitHub
commit 11c27dfd62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 2845 additions and 206 deletions

View file

@ -4,6 +4,10 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
# Public base URL inbound webhooks (e.g. Telegram) are registered on.
# Defaults to APP_URL; set a tunnel URL (e.g. ngrok) for local development.
WEBHOOK_URL=
# Self-hosted mode (skips payment requirements)
SELF_HOSTED=true
@ -152,6 +156,12 @@ PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET=
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
# Telegram (single shared bot — create one via https://t.me/BotFather)
# After setting these, run: php artisan telegram:set-webhook
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
TELEGRAM_WEBHOOK_SECRET=
# AI Services
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
@ -196,6 +206,7 @@ NIGHTWATCH_TOKEN=
# PINTEREST_ENABLED=true
# MASTODON_ENABLED=true
# BLUESKY_ENABLED=true
# TELEGRAM_ENABLED=true
# Media Services
UNSPLASH_ACCESS_KEY=

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Events\TelegramChannelConnected;
use App\Features\SocialAccountLimit;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
use Throwable;
class ConnectTelegramChannel
{
/**
* Link a Telegram chat to a workspace for a one-off connect nonce.
*
* @param array<string, mixed> $chat The `chat` object from the Bot API update.
* @return SocialAccount|null The linked account, or null when blocked (account
* limit reached or the code was already consumed).
*/
public static function execute(Workspace $workspace, array $chat, string $nonce): ?SocialAccount
{
$chatId = (string) data_get($chat, 'id');
$username = data_get($chat, 'username');
// Block only brand-new accounts against the plan limit, never reconnects.
$isNewAccount = ! $workspace->socialAccounts()
->where('platform', Platform::Telegram->value)
->where('platform_user_id', $chatId)
->exists();
if ($isNewAccount && self::workspaceAtAccountLimit($workspace)) {
return null;
}
// Consume the code once so a leaked code can't be replayed to link another chat.
if (! Cache::add("telegram:connect:{$nonce}", true, now()->addMinutes(15))) {
return null;
}
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => Platform::Telegram->value,
'platform_user_id' => $chatId,
],
[
'username' => $username,
'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}",
'avatar_url' => self::fetchChannelAvatar($chatId),
'access_token' => '',
'refresh_token' => '',
'token_expires_at' => null,
'scopes' => [],
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'chat_id' => $chatId,
'username' => $username,
'type' => data_get($chat, 'type'),
'connect_nonce' => $nonce,
],
],
);
TelegramChannelConnected::dispatch($workspace->id, $nonce);
return $account;
}
/**
* Download the channel's photo via the Bot API and store it, returning the path.
*/
private static function fetchChannelAvatar(string $chatId): ?string
{
if (TelegramApi::token() === '') {
return null;
}
try {
$fileId = data_get(Http::get(TelegramApi::endpoint('getChat'), ['chat_id' => $chatId])->json(), 'result.photo.big_file_id');
if (! is_string($fileId)) {
return null;
}
$filePath = data_get(Http::get(TelegramApi::endpoint('getFile'), ['file_id' => $fileId])->json(), 'result.file_path');
if (! is_string($filePath)) {
return null;
}
return uploadFromUrl(TelegramApi::fileUrl($filePath));
} catch (Throwable) {
return null;
}
}
private static function workspaceAtAccountLimit(Workspace $workspace): bool
{
if (config('trypost.self_hosted')) {
return false;
}
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
return $workspace->socialAccounts()->count() >= $limit;
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use RuntimeException;
class RegisterTelegramWebhook
{
/**
* Register the bot webhook (URL + secret token) with the Telegram Bot API.
*
* @return string the webhook URL that was registered
*
* @throws InvalidArgumentException when the bot token or secret is missing
* @throws RuntimeException when Telegram rejects the request
*/
public static function execute(): string
{
$secret = (string) config('trypost.platforms.telegram.webhook_secret');
if (TelegramApi::token() === '' || $secret === '') {
throw new InvalidArgumentException('TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET must both be set.');
}
$url = route('telegram.webhook');
$response = Http::post(TelegramApi::endpoint('setWebhook'), [
'url' => $url,
'secret_token' => $secret,
'allowed_updates' => ['message', 'channel_post', 'message_reaction_count'],
]);
if (! $response->successful() || data_get($response->json(), 'ok') !== true) {
throw new RuntimeException("Failed to set Telegram webhook: {$response->body()}");
}
return $url;
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use Illuminate\Support\Facades\Cache;
class StoreTelegramReactions
{
/**
* Persist the reaction counts pushed by a `message_reaction_count` update
* onto the matching published post, so they surface as post metrics.
*
* @param array<string, mixed> $update The `message_reaction_count` payload.
*/
public static function execute(array $update): void
{
$chatId = (string) data_get($update, 'chat.id');
$messageId = (string) data_get($update, 'message_id');
if ($chatId === '' || $messageId === '') {
return;
}
$postPlatform = PostPlatform::query()
->where('platform', Platform::Telegram->value)
->where('platform_post_id', $messageId)
->whereHas('socialAccount', fn ($query) => $query->where('meta->chat_id', $chatId))
->first();
if ($postPlatform === null) {
return;
}
$rawReactions = data_get($update, 'reactions');
$reactions = array_values(array_map(fn (array $reaction): array => [
'type' => (string) (data_get($reaction, 'type.emoji') ?? __('analytics.metrics.custom_reaction')),
'count' => (int) data_get($reaction, 'total_count'),
], is_array($rawReactions) ? $rawReactions : []));
$postPlatform->update(['meta' => [...$postPlatform->meta ?? [], 'reactions' => $reactions]]);
Cache::forget("post_metrics:{$postPlatform->id}");
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Telegram;
use App\Actions\SocialAccount\RegisterTelegramWebhook;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Throwable;
#[Signature('telegram:set-webhook')]
#[Description('Register the Telegram bot webhook with the configured URL and secret token')]
class SetWebhook extends Command
{
public function handle(): int
{
try {
$url = RegisterTelegramWebhook::execute();
} catch (Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->info("Telegram webhook registered at {$url}");
return self::SUCCESS;
}
}

View file

@ -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,
};
}
}

View file

@ -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,
};
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TelegramChannelConnected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $workspaceId, public string $nonce) {}
public function broadcastAs(): string
{
return 'telegram.channel.connected';
}
public function broadcastOn(): array
{
return [
new PrivateChannel("workspace.{$this->workspaceId}"),
];
}
/**
* @return array<string, string>
*/
public function broadcastWith(): array
{
return [
'nonce' => $this->nonce,
];
}
public function broadcastQueue(): string
{
return 'broadcasts';
}
}

View 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::Unknown,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
public function platform(): string
{
return 'telegram';
}
}

View file

@ -11,6 +11,7 @@
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\Telegram\TelegramAnalytics;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokAnalytics;
use App\Services\Social\XAnalytics;
@ -34,6 +35,7 @@ class AnalyticsController extends Controller
Platform::LinkedInPage,
Platform::Pinterest,
Platform::YouTube,
Platform::Telegram,
];
public function index(Request $request): Response
@ -77,6 +79,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until),
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account),
default => [],
};

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Services\Social\Telegram\TelegramConnectCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class TelegramController extends SocialController
{
protected SocialPlatform $platform = SocialPlatform::Telegram;
/**
* Start a connection: issue a signed one-off code the user posts in their
* channel (`/connect <code>`). The code carries the workspace, so the webhook
* can link the channel without any persisted state. The returned `nonce` lets
* the UI recognise its own connection on the broadcast channel.
*/
public function connect(Request $request): JsonResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
$expiresAt = now()->addMinutes(15);
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);
return response()->json([
'code' => $code,
'nonce' => data_get(TelegramConnectCode::decode($code), 'nonce'),
'bot_username' => config('trypost.platforms.telegram.bot_username'),
'expires_at' => $expiresAt->toIso8601String(),
]);
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Webhooks;
use App\Actions\SocialAccount\ConnectTelegramChannel;
use App\Actions\SocialAccount\StoreTelegramReactions;
use App\Http\Controllers\Controller;
use App\Models\Workspace;
use App\Services\Social\Telegram\TelegramConnectCode;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class TelegramWebhookController extends Controller
{
/**
* Receives Bot API updates. The only update we act on is a `/connect <code>`
* message/channel_post: the signed code carries the workspace, so we link the
* originating channel to it. Everything else is acknowledged and ignored.
*/
public function handle(Request $request): Response
{
$secret = (string) config('trypost.platforms.telegram.webhook_secret');
abort_if(
$secret === '' || ! hash_equals($secret, (string) $request->header('X-Telegram-Bot-Api-Secret-Token')),
SymfonyResponse::HTTP_FORBIDDEN,
);
$update = $request->all();
if (is_array($reactionUpdate = data_get($update, 'message_reaction_count'))) {
StoreTelegramReactions::execute($reactionUpdate);
return response()->noContent();
}
$chat = data_get($update, 'message.chat') ?? data_get($update, 'channel_post.chat');
$text = data_get($update, 'message.text') ?? data_get($update, 'channel_post.text');
if (! is_array($chat) || ! is_string($text) || ! preg_match('/^\/connect(?:@\S+)?\s+(\S+)/', $text, $matches)) {
return response()->noContent();
}
$payload = TelegramConnectCode::decode($matches[1]);
$workspace = $payload === null ? null : Workspace::find(data_get($payload, 'workspace_id'));
if ($workspace !== null) {
ConnectTelegramChannel::execute($workspace, $chat, data_get($payload, 'nonce'));
}
return response()->noContent();
}
}

View file

@ -25,6 +25,7 @@
use App\Services\Social\LinkedInPublisher;
use App\Services\Social\MastodonPublisher;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\Telegram\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),
};
}

View file

@ -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,
};
},

View file

@ -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,
],
};
}
}

View file

@ -13,6 +13,7 @@
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\MastodonAnalytics;
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\Telegram\TelegramAnalytics;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\XAnalytics;
use App\Services\Social\YouTubeAnalytics;
@ -64,6 +65,7 @@ public function forPlatform(PostPlatform $postPlatform): array
Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform),

View file

@ -8,6 +8,7 @@
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@ -66,6 +67,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool
Platform::Pinterest => $this->verifyPinterest($account),
Platform::Bluesky => $this->verifyBluesky($account),
Platform::Mastodon => $this->verifyMastodon($account),
Platform::Telegram => $this->verifyTelegram($account),
};
}
@ -504,6 +506,16 @@ private function verifyBluesky(SocialAccount $account): bool
return $response->successful();
}
private function verifyTelegram(SocialAccount $account): bool
{
// getChat succeeds only while the bot can still reach the chat.
$response = Http::get(TelegramApi::endpoint('getChat'), [
'chat_id' => data_get($account->meta, 'chat_id'),
]);
return $response->successful() && data_get($response->json(), 'ok') === true;
}
private function verifyMastodon(SocialAccount $account): bool
{
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');

View file

@ -13,10 +13,39 @@ 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']);
// Telegram requires every <a> to carry an href; drop bare anchors so the parser doesn't reject the whole message.
$content = preg_replace('/<a(?![^>]*\shref=)[^>]*>(.*?)<\/a>/is', '$1', $content);
// Escape bare ampersands while leaving existing entities intact.
$content = preg_replace('/&(?!(?:amp|lt|gt|quot|#\d+);)/', '&amp;', $content);
$content = preg_replace("/\n{3,}/", "\n\n", $content);
return trim($content);
}
private function stripHtml(string $content): string
{
// Convert <p> tags to newlines

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Telegram;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
use Throwable;
class TelegramAnalytics
{
/**
* Account-level metrics. The Bot API only exposes the subscriber count.
*
* @return array<int, array{label: string, value: int}>
*/
public function getMetrics(SocialAccount $account): array
{
$chatId = data_get($account->meta, 'chat_id');
if (TelegramApi::token() === '' || $chatId === null) {
return [];
}
try {
$count = data_get(
Http::get(TelegramApi::endpoint('getChatMemberCount'), ['chat_id' => $chatId])->json(),
'result',
);
} catch (Throwable) {
return [];
}
if (! is_int($count)) {
return [];
}
return [
['label' => __('analytics.metrics.subscribers'), 'value' => $count],
];
}
/**
* Post-level metrics. Reaction counts are pushed by the webhook and stored
* on the post platform's meta (the Bot API offers no post views to bots).
*
* @return array<int, array{label: string, value: int, kind?: string}>
*/
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$metrics = $account
? array_map(fn (array $metric): array => [...$metric, 'kind' => 'subscribers'], $this->getMetrics($account))
: [];
$reactions = data_get($postPlatform->meta, 'reactions', []);
if (is_array($reactions)) {
foreach ($reactions as $reaction) {
$metrics[] = [
'label' => (string) data_get($reaction, 'type'),
'value' => (int) data_get($reaction, 'count'),
'kind' => 'reaction',
];
}
}
return $metrics;
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Telegram;
/**
* Builds Telegram Bot API URLs from the configured token + host, so the
* `bot<token>/<method>` shape and config keys live in one place.
*/
class TelegramApi
{
public static function token(): string
{
return (string) config('trypost.platforms.telegram.bot_token');
}
/**
* Endpoint for a Bot API method, e.g. `https://api.telegram.org/bot<token>/sendMessage`.
*/
public static function endpoint(string $method): string
{
$base = self::baseUrl();
$token = self::token();
return "{$base}/bot{$token}/{$method}";
}
/**
* Download URL for a file path returned by `getFile`.
*/
public static function fileUrl(string $path): string
{
$base = self::baseUrl();
$token = self::token();
return "{$base}/file/bot{$token}/{$path}";
}
private static function baseUrl(): string
{
return rtrim((string) config('trypost.platforms.telegram.api'), '/');
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Telegram;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
/**
* A stateless, signed `/connect` code. It carries the workspace it belongs to,
* a one-off nonce (so the polling UI can recognise its own connection) and an
* expiry encrypted with the app key, so the webhook can trust it without any
* database lookup.
*/
class TelegramConnectCode
{
public static function issue(string $workspaceId, CarbonInterface $expiresAt): string
{
return Crypt::encryptString((string) json_encode([
'workspace_id' => $workspaceId,
'nonce' => Str::lower(Str::random(16)),
'expires_at' => $expiresAt->getTimestamp(),
]));
}
/**
* Decode and validate a code, returning its payload or null when the code is
* missing, tampered with, malformed, or expired.
*
* @return array{workspace_id: string, nonce: string, expires_at: int}|null
*/
public static function decode(mixed $code): ?array
{
if (! is_string($code) || $code === '') {
return null;
}
try {
$payload = json_decode(Crypt::decryptString($code), true);
} catch (DecryptException) {
return null;
}
if (
! is_array($payload)
|| ! is_string(data_get($payload, 'workspace_id'))
|| ! is_string(data_get($payload, 'nonce'))
|| now()->getTimestamp() > (int) data_get($payload, 'expires_at')
) {
return null;
}
return $payload;
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Telegram;
use App\DataTransferObjects\MediaItem;
/**
* Telegram's media kinds, as used both in the `sendMediaGroup` `type` field and
* to pick the matching single-media Bot API method.
*/
enum TelegramMediaType: string
{
case Photo = 'photo';
case Video = 'video';
case Document = 'document';
public static function for(MediaItem $media): self
{
return match (true) {
$media->isImage() => self::Photo,
$media->isVideo() => self::Video,
default => self::Document,
};
}
/**
* The Bot API method that sends a single media of this type.
*/
public function sendMethod(): string
{
return match ($this) {
self::Photo => 'sendPhoto',
self::Video => 'sendVideo',
self::Document => 'sendDocument',
};
}
}

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Telegram;
use App\DataTransferObjects\MediaItem;
use App\Exceptions\Social\TelegramPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use App\Services\Social\ContentSanitizer;
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: TelegramMediaType, url: string} $item
*/
private function sendSingleMedia(string $chatId, array $item, string $caption): int
{
$type = $item['type'];
$response = $this->call($type->sendMethod(), [
'chat_id' => $chatId,
$type->value => $item['url'],
'caption' => $caption,
'parse_mode' => 'HTML',
]);
return (int) data_get($response->json(), 'result.message_id');
}
/**
* @param array<int, array{type: TelegramMediaType, url: string}> $items
*/
private function sendMediaGroup(string $chatId, array $items, string $caption): int
{
$group = [];
foreach ($items as $index => $item) {
$entry = ['type' => $item['type']->value, 'media' => $item['url']];
if ($index === 0 && $caption !== '') {
$entry['caption'] = $caption;
$entry['parse_mode'] = 'HTML';
}
$group[] = $entry;
}
$response = $this->call('sendMediaGroup', [
'chat_id' => $chatId,
'media' => json_encode($group),
]);
return (int) data_get($response->json(), 'result.0.message_id');
}
/**
* @return array{type: TelegramMediaType, url: string}
*/
private function telegramMedia(MediaItem $media): array
{
return ['type' => TelegramMediaType::for($media), 'url' => $media->url];
}
private function call(string $method, array $payload): Response
{
$response = $this->socialHttp()->post(TelegramApi::endpoint($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);
}
}

View file

@ -40,6 +40,7 @@
$middleware->preventRequestForgery(except: [
'stripe/*',
'telegram/webhook',
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View file

@ -54,7 +54,20 @@
|
*/
'url' => env('APP_URL', 'https://trypost.it'),
'url' => env('APP_URL', 'https://app.trypost.it'),
/*
|--------------------------------------------------------------------------
| Webhook URL
|--------------------------------------------------------------------------
|
| Public base URL that inbound provider webhooks (e.g. Telegram) are
| registered on. Defaults to the app URL; override it (for example with a
| tunnel like ngrok) when the app URL isn't reachable from the internet.
|
*/
'webhook_url' => env('WEBHOOK_URL', env('APP_URL', 'https://app.trypost.it')),
/*
|--------------------------------------------------------------------------

View file

@ -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'),
],
],
];

View file

@ -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) => [

View file

@ -4,6 +4,10 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
# Public base URL inbound webhooks (e.g. Telegram) are registered on.
# Defaults to APP_URL; set a tunnel URL (e.g. ngrok) for local development.
WEBHOOK_URL=
# Self-hosted mode (skips payment requirements)
SELF_HOSTED=true
@ -128,6 +132,12 @@ PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET=
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
# Telegram (single shared bot — create one via https://t.me/BotFather)
# After setting these, run: php artisan telegram:set-webhook
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
TELEGRAM_WEBHOOK_SECRET=
# AI Services
OPENAI_API_KEY=
ANTHROPIC_API_KEY=

View file

@ -51,6 +51,7 @@
'pinterest' => 'Connect your Pinterest account',
'bluesky' => 'Connect your Bluesky account',
'mastodon' => 'Connect your Mastodon account',
'telegram' => 'Connect a Telegram channel or group',
],
'disconnect_modal' => [
@ -82,6 +83,22 @@
'submitting' => 'Connecting...',
],
'telegram' => [
'title' => 'Connect Telegram',
'description' => 'Link a channel or group',
'step_admin' => 'Add :bot as an administrator to your Telegram channel or group.',
'step_command' => 'Post this command in the channel or group:',
'waiting' => 'Waiting for the channel to connect…',
'connected' => 'Channel connected!',
'connected_toast' => 'Telegram channel connected successfully!',
'copied_toast' => 'Command copied to clipboard',
'copy_tooltip' => 'Copy command',
'expired' => 'This code has expired. Generate a new one to try again.',
'new_code' => 'Generate a new code',
'retry' => 'Try again',
'error_generic' => 'Could not start the connection. Please try again.',
],
'facebook' => [
'title' => 'Select Facebook Page',
'description' => 'Choose which page you want to connect',

View file

@ -13,6 +13,7 @@
'bookmarks' => 'Bookmarks',
'clicks' => 'Clicks',
'comments' => 'Comments',
'custom_reaction' => 'Custom',
'engagement' => 'Engagement',
'favourites' => 'Favourites',
'followers' => 'Followers',
@ -42,6 +43,7 @@
'retweets' => 'Retweets',
'saves' => 'Saves',
'shares' => 'Shares',
'subscribers' => 'Subscribers',
'subscribers_gained' => 'Subscribers Gained',
'subscribers_lost' => 'Subscribers Lost',
'total_likes' => 'Total Likes',

View file

@ -484,6 +484,10 @@
'label' => 'Post',
'description' => 'Text post with optional media',
],
'telegram_post' => [
'label' => 'Post',
'description' => 'Text post with optional media',
],
],
'platforms' => [
@ -586,6 +590,7 @@
'bluesky_post' => 'Bluesky Post',
'threads_post' => 'Threads Post',
'mastodon_post' => 'Mastodon Post',
'telegram_post' => 'Telegram Post',
'facebook_post' => 'Facebook Post',
'pinterest_pin' => 'Pinterest Pin',
'instagram_story' => 'Instagram Story',

View file

@ -51,6 +51,7 @@
'pinterest' => 'Conecta tu cuenta de Pinterest',
'bluesky' => 'Conecta tu cuenta de Bluesky',
'mastodon' => 'Conecta tu cuenta de Mastodon',
'telegram' => 'Conecta un canal o grupo de Telegram',
],
'disconnect_modal' => [
@ -82,6 +83,22 @@
'submitting' => 'Conectando...',
],
'telegram' => [
'title' => 'Conectar Telegram',
'description' => 'Vincula un canal o grupo',
'step_admin' => 'Añade :bot como administrador de tu canal o grupo de Telegram.',
'step_command' => 'Publica este comando en el canal o grupo:',
'waiting' => 'Esperando a que el canal se conecte…',
'connected' => '¡Canal conectado!',
'connected_toast' => '¡Canal de Telegram conectado correctamente!',
'copied_toast' => 'Comando copiado al portapapeles',
'copy_tooltip' => 'Copiar comando',
'expired' => 'Este código ha caducado. Genera uno nuevo para volver a intentarlo.',
'new_code' => 'Generar un nuevo código',
'retry' => 'Reintentar',
'error_generic' => 'No se pudo iniciar la conexión. Inténtalo de nuevo.',
],
'facebook' => [
'title' => 'Seleccionar página de Facebook',
'description' => 'Elige qué página deseas conectar',

View file

@ -13,6 +13,7 @@
'bookmarks' => 'Guardados',
'clicks' => 'Clics',
'comments' => 'Comentarios',
'custom_reaction' => 'Personalizada',
'engagement' => 'Engagement',
'favourites' => 'Favoritos',
'followers' => 'Seguidores',
@ -42,6 +43,7 @@
'retweets' => 'Retweets',
'saves' => 'Guardados',
'shares' => 'Compartidos',
'subscribers' => 'Suscriptores',
'subscribers_gained' => 'Suscriptores Ganados',
'subscribers_lost' => 'Suscriptores Perdidos',
'total_likes' => 'Total de Me gusta',

View file

@ -484,6 +484,10 @@
'label' => 'Post',
'description' => 'Post de texto con multimedia opcional',
],
'telegram_post' => [
'label' => 'Post',
'description' => 'Post de texto con multimedia opcional',
],
],
'platforms' => [
@ -587,6 +591,7 @@
'bluesky_post' => 'Post en Bluesky',
'threads_post' => 'Post en Threads',
'mastodon_post' => 'Post en Mastodon',
'telegram_post' => 'Post en Telegram',
'facebook_post' => 'Post en Facebook',
'pinterest_pin' => 'Pin de Pinterest',
'instagram_story' => 'Story de Instagram',

View file

@ -51,6 +51,7 @@
'pinterest' => 'Conecte sua conta do Pinterest',
'bluesky' => 'Conecte sua conta do Bluesky',
'mastodon' => 'Conecte sua conta do Mastodon',
'telegram' => 'Conecte um canal ou grupo do Telegram',
],
'disconnect_modal' => [
@ -82,6 +83,22 @@
'submitting' => 'Conectando...',
],
'telegram' => [
'title' => 'Conectar Telegram',
'description' => 'Vincule um canal ou grupo',
'step_admin' => 'Adicione :bot como administrador do seu canal ou grupo do Telegram.',
'step_command' => 'Publique este comando no canal ou grupo:',
'waiting' => 'Aguardando o canal conectar…',
'connected' => 'Canal conectado!',
'connected_toast' => 'Canal do Telegram conectado com sucesso!',
'copied_toast' => 'Comando copiado para a área de transferência',
'copy_tooltip' => 'Copiar comando',
'expired' => 'Este código expirou. Gere um novo para tentar de novo.',
'new_code' => 'Gerar um novo código',
'retry' => 'Tentar novamente',
'error_generic' => 'Não foi possível iniciar a conexão. Tente novamente.',
],
'facebook' => [
'title' => 'Selecionar Página do Facebook',
'description' => 'Escolha qual página você deseja conectar',

View file

@ -13,6 +13,7 @@
'bookmarks' => 'Salvos',
'clicks' => 'Cliques',
'comments' => 'Comentários',
'custom_reaction' => 'Personalizada',
'engagement' => 'Engajamento',
'favourites' => 'Favoritos',
'followers' => 'Seguidores',
@ -42,6 +43,7 @@
'retweets' => 'Retweets',
'saves' => 'Salvos',
'shares' => 'Compartilhamentos',
'subscribers' => 'Inscritos',
'subscribers_gained' => 'Inscritos Ganhos',
'subscribers_lost' => 'Inscritos Perdidos',
'total_likes' => 'Curtidas Totais',

View file

@ -484,6 +484,10 @@
'label' => 'Post',
'description' => 'Post de texto com mídia opcional',
],
'telegram_post' => [
'label' => 'Post',
'description' => 'Post de texto com mídia opcional',
],
],
'platforms' => [
@ -586,6 +590,7 @@
'bluesky_post' => 'Post no Bluesky',
'threads_post' => 'Post no Threads',
'mastodon_post' => 'Post no Mastodon',
'telegram_post' => 'Post no Telegram',
'facebook_post' => 'Post no Facebook',
'pinterest_pin' => 'Pin no Pinterest',
'instagram_story' => 'Story do Instagram',

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,13 +1,25 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconAlertCircle, IconCheck, IconExternalLink, IconRefresh, IconTrash } from '@tabler/icons-vue';
import {
IconAlertCircle,
IconCheck,
IconExternalLink,
IconRefresh,
IconTrash,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted } from 'vue';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { toggle as toggleAccount } from '@/routes/app/accounts';
@ -47,44 +59,16 @@ const props = withDefaults(defineProps<Props>(), {
});
const handleToggle = (accountId: string) => {
router.put(toggleAccount.url(accountId), {}, {
preserveScroll: true,
});
};
const getConnectUrl = (platformValue: string): string => {
return `/connect/${platformValue}`;
};
const openOAuthPopup = (platformValue: string) => {
const url = getConnectUrl(platformValue);
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
window.open(
url,
'oauth-popup',
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
router.put(
toggleAccount.url(accountId),
{},
{
preserveScroll: true,
},
);
};
const handleOAuthMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'social-oauth-callback') return;
// Reload the page to get fresh data
router.reload();
};
onMounted(() => {
window.addEventListener('message', handleOAuthMessage);
});
onUnmounted(() => {
window.removeEventListener('message', handleOAuthMessage);
});
const { openOAuthPopup } = useOAuthPopup(() => router.reload());
const gridClass = computed(() => {
switch (props.columns) {
@ -102,7 +86,11 @@ const emit = defineEmits<{
disconnect: [accountId: string];
}>();
const getProfileUrl = (platform: string, username: string | null, platformUserId: string | null = null): string | null => {
const getProfileUrl = (
platform: string,
username: string | null,
platformUserId: string | null = null,
): string | null => {
if (platform === 'facebook') {
const identifier = username || platformUserId;
return identifier ? `https://facebook.com/${identifier}` : null;
@ -111,65 +99,112 @@ const getProfileUrl = (platform: string, username: string | null, platformUserId
if (!username) return null;
const urls: Record<string, string> = {
'linkedin': `https://linkedin.com/in/${username}`,
linkedin: `https://linkedin.com/in/${username}`,
'linkedin-page': `https://linkedin.com/company/${username}`,
'x': `https://x.com/${username}`,
'tiktok': `https://tiktok.com/@${username}`,
'instagram': `https://instagram.com/${username}`,
x: `https://x.com/${username}`,
tiktok: `https://tiktok.com/@${username}`,
instagram: `https://instagram.com/${username}`,
'instagram-facebook': `https://instagram.com/${username}`,
'youtube': `https://youtube.com/@${username}`,
'threads': `https://threads.net/@${username}`,
'bluesky': `https://bsky.app/profile/${username}`,
'pinterest': `https://pinterest.com/${username}`,
youtube: `https://youtube.com/@${username}`,
threads: `https://threads.net/@${username}`,
bluesky: `https://bsky.app/profile/${username}`,
pinterest: `https://pinterest.com/${username}`,
telegram: `https://t.me/${username}`,
};
return urls[platform] || null;
};
const isDisconnected = (account: SocialAccount | null): boolean => {
if (!account) return false;
return account.status === 'disconnected' || account.status === 'token_expired';
return (
account.status === 'disconnected' || account.status === 'token_expired'
);
};
</script>
<template>
<div class="grid gap-4" :class="gridClass">
<div v-for="platform in platforms" :key="platform.value"
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md" :class="{
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20': platform.connected && !isDisconnected(platform.account) && platform.account?.is_active,
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20': platform.connected && (isDisconnected(platform.account) || !platform.account?.is_active),
}">
<div
v-for="platform in platforms"
:key="platform.value"
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md"
:class="{
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20':
platform.connected &&
!isDisconnected(platform.account) &&
platform.account?.is_active,
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20':
platform.connected &&
(isDisconnected(platform.account) ||
!platform.account?.is_active),
}"
>
<!-- Platform Header -->
<div class="flex items-center gap-3 p-4">
<div class="relative">
<img :src="getPlatformLogo(platform.value)" :alt="platform.label"
<img
:src="getPlatformLogo(platform.value)"
:alt="platform.label"
class="h-10 w-10 rounded-full object-contain"
:class="{ 'opacity-40': platform.connected && platform.account && !platform.account.is_active }" />
<div v-if="platform.connected && !isDisconnected(platform.account)"
class="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900">
:class="{
'opacity-40':
platform.connected &&
platform.account &&
!platform.account.is_active,
}"
/>
<div
v-if="
platform.connected &&
!isDisconnected(platform.account)
"
class="absolute -right-0.5 -bottom-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900"
>
<IconCheck class="h-2 w-2" />
</div>
<div v-else-if="platform.connected && isDisconnected(platform.account)"
class="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900">
<div
v-else-if="
platform.connected &&
isDisconnected(platform.account)
"
class="absolute -right-0.5 -bottom-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900"
>
<IconAlertCircle class="h-2 w-2" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0">
<h3 class="font-semibold leading-tight">
<div class="flex min-w-0 items-center gap-1">
<h3 class="leading-tight font-semibold">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}<br />
<span class="text-xs font-normal text-muted-foreground">({{
platform.label.split('(')[1] }}</span>
{{ platform.label.split('(')[0].trim()
}}<br />
<span
class="text-xs font-normal text-muted-foreground"
>({{
platform.label.split('(')[1]
}}</span
>
</template>
<template v-else>{{ platform.label }}</template>
</h3>
</div>
<Switch v-if="platform.connected && platform.account" :model-value="platform.account.is_active"
@update:model-value="handleToggle(platform.account.id)" />
<Switch
v-if="platform.connected && platform.account"
:model-value="platform.account.is_active"
@update:model-value="
handleToggle(platform.account.id)
"
/>
</div>
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
@{{ platform.account.username || platform.account.display_name }}
<p
v-if="platform.connected && platform.account"
class="truncate text-sm text-muted-foreground"
>
@{{
platform.account.username ||
platform.account.display_name
}}
</p>
<p v-else class="text-sm text-muted-foreground">
{{ trans('accounts.not_connected') }}
@ -178,14 +213,24 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</div>
<!-- Connected State -->
<div v-if="platform.connected && platform.account" class="border-t px-4 py-3">
<div
v-if="platform.connected && platform.account"
class="border-t px-4 py-3"
>
<!-- Disconnected Warning -->
<div v-if="isDisconnected(platform.account)"
class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<IconAlertCircle class="h-4 w-4 mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<p class="font-medium">{{ trans('accounts.connection_lost') }}</p>
<p v-if="platform.account.error_message" class="text-xs truncate opacity-80">
<div
v-if="isDisconnected(platform.account)"
class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<IconAlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
<div class="min-w-0 flex-1">
<p class="font-medium">
{{ trans('accounts.connection_lost') }}
</p>
<p
v-if="platform.account.error_message"
class="truncate text-xs opacity-80"
>
{{ platform.account.error_message }}
</p>
</div>
@ -194,38 +239,77 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Avatar class="h-8 w-8">
<AvatarImage v-if="platform.account.avatar_url" :src="platform.account.avatar_url" />
<AvatarImage
v-if="platform.account.avatar_url"
:src="platform.account.avatar_url"
/>
<AvatarFallback class="text-xs">
{{ platform.account.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<span class="text-sm font-medium truncate max-w-[120px]">
<span
class="max-w-[120px] truncate text-sm font-medium"
>
{{ platform.account.display_name }}
</span>
</div>
<div class="flex items-center gap-1">
<!-- Reconnect button for disconnected accounts -->
<TooltipProvider v-if="showReconnect && isDisconnected(platform.account)">
<TooltipProvider
v-if="
showReconnect &&
isDisconnected(platform.account)
"
>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon"
<Button
variant="ghost"
size="icon"
class="size-8 text-amber-600 hover:text-amber-700"
@click="openOAuthPopup(platform.value)">
@click="openOAuthPopup(platform.value)"
>
<IconRefresh class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ trans('accounts.reconnect_account') }}</p>
<p>
{{
trans('accounts.reconnect_account')
}}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider
v-if="showViewProfile && getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)">
v-if="
showViewProfile &&
getProfileUrl(
platform.value,
platform.account.username,
platform.account.platform_user_id,
)
"
>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<a :href="getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)!"
target="_blank">
<Button
variant="ghost"
size="icon"
class="size-8"
as-child
>
<a
:href="
getProfileUrl(
platform.value,
platform.account.username,
platform.account
.platform_user_id,
)!
"
target="_blank"
>
<IconExternalLink class="size-4" />
</a>
</Button>
@ -238,8 +322,17 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<TooltipProvider v-if="showDisconnect">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8"
@click="emit('disconnect', platform.account.id)">
<Button
variant="ghost"
size="icon"
class="size-8"
@click="
emit(
'disconnect',
platform.account.id,
)
"
>
<IconTrash class="size-4" />
</Button>
</TooltipTrigger>
@ -254,7 +347,12 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<!-- Not Connected State -->
<div v-else class="border-t px-4 py-3">
<Button variant="outline" class="w-full" size="sm" @click="openOAuthPopup(platform.value)">
<Button
variant="outline"
class="w-full"
size="sm"
@click="openOAuthPopup(platform.value)"
>
{{ trans('accounts.connect') }}
</Button>
</div>

View file

@ -2,8 +2,9 @@
import { router } from '@inertiajs/vue3';
import { IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onMounted, onUnmounted } from 'vue';
import { ref } from 'vue';
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -12,6 +13,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { Platform } from '@/types/platform';
export interface AvailablePlatform {
value: string;
@ -32,116 +35,169 @@ const getPlatformDescription = (platform: string): string =>
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<string, { bg: string; rotate: string; image: string }> = {
instagram: { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' },
'instagram-facebook': { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' },
facebook: { bg: 'bg-sky-200', rotate: 'rotate-1', image: '/images/accounts/facebook.png' },
linkedin: { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' },
'linkedin-page': { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' },
x: { bg: 'bg-amber-200', rotate: 'rotate-2', image: '/images/accounts/x.png' },
tiktok: { bg: 'bg-fuchsia-200', rotate: '-rotate-1', image: '/images/accounts/tiktok.png' },
youtube: { bg: 'bg-red-200', rotate: 'rotate-1', image: '/images/accounts/youtube.png' },
pinterest: { bg: 'bg-rose-200', rotate: '-rotate-2', image: '/images/accounts/pinterest.png' },
threads: { bg: 'bg-emerald-200', rotate: 'rotate-2', image: '/images/accounts/threads.png' },
bluesky: { bg: 'bg-cyan-200', rotate: '-rotate-1', image: '/images/accounts/bluesky.png' },
mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1', image: '/images/accounts/mastodon.png' },
const platformTheme: Record<
string,
{ bg: string; rotate: string; image: string }
> = {
instagram: {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
'instagram-facebook': {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
facebook: {
bg: 'bg-sky-200',
rotate: 'rotate-1',
image: '/images/accounts/facebook.png',
},
linkedin: {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
'linkedin-page': {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
x: {
bg: 'bg-amber-200',
rotate: 'rotate-2',
image: '/images/accounts/x.png',
},
tiktok: {
bg: 'bg-fuchsia-200',
rotate: '-rotate-1',
image: '/images/accounts/tiktok.png',
},
youtube: {
bg: 'bg-red-200',
rotate: 'rotate-1',
image: '/images/accounts/youtube.png',
},
pinterest: {
bg: 'bg-rose-200',
rotate: '-rotate-2',
image: '/images/accounts/pinterest.png',
},
threads: {
bg: 'bg-emerald-200',
rotate: 'rotate-2',
image: '/images/accounts/threads.png',
},
bluesky: {
bg: 'bg-cyan-200',
rotate: '-rotate-1',
image: '/images/accounts/bluesky.png',
},
mastodon: {
bg: 'bg-violet-200',
rotate: 'rotate-1',
image: '/images/accounts/mastodon.png',
},
telegram: {
bg: 'bg-sky-200',
rotate: '-rotate-2',
image: '/images/accounts/telegram.png',
},
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const openOAuthPopup = (platformValue: string) => {
const url = `/connect/${platformValue}`;
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
open.value = false;
window.open(
url,
'oauth-popup',
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
);
};
const handleOAuthMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'social-oauth-callback') return;
const telegramOpen = ref(false);
const { openOAuthPopup } = useOAuthPopup(() => {
open.value = false;
router.reload();
});
const connectPlatform = (platformValue: string) => {
open.value = false;
if (platformValue === Platform.Telegram) {
telegramOpen.value = true;
return;
}
openOAuthPopup(platformValue);
};
onMounted(() => {
window.addEventListener('message', handleOAuthMessage);
});
onUnmounted(() => {
window.removeEventListener('message', handleOAuthMessage);
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{{ $t('accounts.add_social_title') }}</DialogTitle>
<DialogDescription>
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<div>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{{
$t('accounts.add_social_title')
}}</DialogTitle>
<DialogDescription>
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div
v-for="platform in platforms"
:key="platform.value"
class="group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 text-center shadow-xs transition-shadow hover:shadow-md"
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"
>
<!-- "+" sticker badge appears only on hover so the grid doesn't feel cluttered. -->
<span
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:rotate-90 group-hover:scale-110 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
v-for="platform in platforms"
:key="platform.value"
class="group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 text-center shadow-xs transition-shadow hover:shadow-md"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="flex-1">
<span class="block text-sm font-semibold text-foreground">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
<span
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:scale-110 group-hover:rotate-90 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<p class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60">
{{ getPlatformDescription(platform.value) }}
</p>
</div>
<Button
size="sm"
class="mt-auto w-full"
@click="openOAuthPopup(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="flex-1">
<span
class="block text-sm font-semibold text-foreground"
>
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p
class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60"
>
{{ getPlatformDescription(platform.value) }}
</p>
</div>
<Button
size="sm"
class="mt-auto w-full"
@click="connectPlatform(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
<TelegramConnectDialog v-model:open="telegramOpen" />
</div>
</template>

View file

@ -0,0 +1,243 @@
<script setup lang="ts">
import { router, useHttp } from '@inertiajs/vue3';
import { IconCheck, IconCopy, IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onUnmounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useWorkspaceEcho } from '@/composables/echo/useWorkspaceEcho';
import dayjs from '@/dayjs';
import { copyToClipboard } from '@/lib/utils';
import { connect as connectTelegram } from '@/routes/app/social/telegram';
const open = defineModel<boolean>('open', { required: true });
type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error';
interface ConnectResponse {
code: string;
nonce: string;
bot_username: string;
expires_at: string;
}
const SUCCESS_CLOSE_DELAY_MS = 1200;
const phase = ref<Phase>('loading');
const code = ref('');
const nonce = ref('');
const botUsername = ref('');
const errorMessage = ref('');
const httpConnect = useHttp<Record<string, never>, ConnectResponse>({});
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
const clearExpiry = () => {
if (expiryTimer !== null) {
clearTimeout(expiryTimer);
expiryTimer = null;
}
};
// The channel is linked server-side by the webhook; Reverb pushes the result here.
useWorkspaceEcho<{ nonce: string }>(
'.telegram.channel.connected',
(payload) => {
if (phase.value !== 'ready' || payload.nonce !== nonce.value) {
return;
}
phase.value = 'connected';
clearExpiry();
toast.success(trans('accounts.telegram.connected_toast'));
setTimeout(() => {
open.value = false;
router.reload();
}, SUCCESS_CLOSE_DELAY_MS);
},
);
const start = async () => {
phase.value = 'loading';
errorMessage.value = '';
try {
const response = await httpConnect.post(connectTelegram.url());
code.value = response.code;
nonce.value = response.nonce;
botUsername.value = response.bot_username;
phase.value = 'ready';
clearExpiry();
expiryTimer = setTimeout(
() => {
if (phase.value === 'ready') {
phase.value = 'expired';
}
},
Math.max(0, dayjs(response.expires_at).diff(dayjs())),
);
} catch (error) {
phase.value = 'error';
errorMessage.value =
(error as { response?: { data?: { message?: string } } })?.response
?.data?.message ?? trans('accounts.telegram.error_generic');
}
};
const copyCommand = () => {
copyToClipboard(
`/connect ${code.value}`,
trans('accounts.telegram.copied_toast'),
);
};
watch(open, (isOpen) => {
if (isOpen) {
start();
} else {
clearExpiry();
}
});
onUnmounted(clearExpiry);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<div class="flex items-start gap-3">
<img
src="/images/accounts/telegram.png"
alt="Telegram"
class="size-10 rounded-lg"
/>
<div class="text-left">
<DialogTitle>{{
$t('accounts.telegram.title')
}}</DialogTitle>
<DialogDescription>{{
$t('accounts.telegram.description')
}}</DialogDescription>
</div>
</div>
</DialogHeader>
<div
v-if="phase === 'loading'"
class="flex items-center justify-center py-10"
>
<IconLoader2
class="size-6 animate-spin text-muted-foreground"
/>
</div>
<div v-else-if="phase === 'error'" class="space-y-4 py-2">
<p class="text-sm text-destructive">{{ errorMessage }}</p>
<Button class="w-full" @click="start">{{
$t('accounts.telegram.retry')
}}</Button>
</div>
<div v-else-if="phase === 'expired'" class="space-y-4 py-2">
<p class="text-sm text-muted-foreground">
{{ $t('accounts.telegram.expired') }}
</p>
<Button class="w-full" @click="start">{{
$t('accounts.telegram.new_code')
}}</Button>
</div>
<div
v-else-if="phase === 'connected'"
class="flex flex-col items-center gap-3 py-8 text-center"
>
<span
class="inline-flex size-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600"
>
<IconCheck class="size-6" stroke-width="3" />
</span>
<p class="text-sm font-medium">
{{ $t('accounts.telegram.connected') }}
</p>
</div>
<div v-else class="min-w-0 space-y-5 py-2">
<ol class="space-y-4 text-sm">
<li class="flex gap-3">
<span
class="flex size-6 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-xs font-semibold"
>1</span
>
<span>{{
trans('accounts.telegram.step_admin', {
bot: `@${botUsername}`,
})
}}</span>
</li>
<li class="flex gap-3">
<span
class="flex size-6 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-xs font-semibold"
>2</span
>
<div class="min-w-0 flex-1 space-y-2">
<span>{{
$t('accounts.telegram.step_command')
}}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg border bg-muted px-3 py-2 text-left font-mono text-sm transition-colors hover:bg-muted/70"
@click="copyCommand"
>
<span class="min-w-0 truncate"
>/connect {{ code }}</span
>
<IconCopy
class="size-4 shrink-0 text-muted-foreground group-hover:text-foreground"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>
{{
$t(
'accounts.telegram.copy_tooltip',
)
}}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</li>
</ol>
<div
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<IconLoader2 class="size-4 animate-spin" />
{{ $t('accounts.telegram.waiting') }}
</div>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,57 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(
() => props.accountId,
() => {
fetchMetrics();
},
);
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: false });
</script>
<template>
<MetricsGrid
:metrics="metrics"
:loading="isLoading"
:empty-label="trans('analytics.no_data')"
/>
</template>

View file

@ -1,13 +1,21 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { IconChartBar, IconLoader2 } from '@tabler/icons-vue';
import { IconChartBar, IconLoader2, IconUsers } from '@tabler/icons-vue';
import { computed, onMounted, ref } from 'vue';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatNumberCompact } from '@/lib/utils';
import { metrics as metricsRoute } from '@/routes/app/posts/platforms';
interface Metric {
label: string;
value: number;
kind?: string;
}
type MetricsResponse = Metric[] | { unsupported: true; reason: string };
@ -22,20 +30,25 @@ const props = defineProps<Props>();
const loading = ref(true);
const metrics = ref<Metric[]>([]);
const hasMetrics = computed(() => metrics.value.length > 0);
const stats = computed(() => metrics.value.filter((m) => !m.kind));
const subscribers = computed(() =>
metrics.value.find((m) => m.kind === 'subscribers'),
);
const reactions = computed(() =>
metrics.value.filter((m) => m.kind === 'reaction'),
);
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toString();
};
const hasMetrics = computed(() => metrics.value.length > 0);
const http = useHttp<Record<string, never>, MetricsResponse>({});
onMounted(async () => {
try {
const response = await http.get(
metricsRoute.url({ post: props.postId, postPlatform: props.postPlatformId }),
metricsRoute.url({
post: props.postId,
postPlatform: props.postPlatformId,
}),
);
if (Array.isArray(response)) {
@ -61,20 +74,69 @@ onMounted(async () => {
<!-- Loaded with data: full metrics block. -->
<div v-else-if="hasMetrics" class="border-t px-4 py-3">
<div class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div
class="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase"
>
<IconChartBar class="h-3 w-3" />
{{ $t('posts.show.metrics') }}
</div>
<div class="grid grid-cols-3 gap-2">
<div v-if="stats.length > 0" class="grid grid-cols-3 gap-2">
<div
v-for="metric in metrics"
v-for="metric in stats"
:key="metric.label"
class="rounded-md bg-muted/50 px-2.5 py-1.5"
>
<p class="text-[10px] uppercase tracking-wider text-muted-foreground">{{ metric.label }}</p>
<p class="text-sm font-semibold tabular-nums">{{ formatNumber(metric.value) }}</p>
<p
class="text-[10px] tracking-wider text-muted-foreground uppercase"
>
{{ metric.label }}
</p>
<p class="text-sm font-semibold tabular-nums">
{{ formatNumberCompact(metric.value) }}
</p>
</div>
</div>
<div
v-if="subscribers || reactions.length > 0"
class="flex flex-wrap items-center gap-1.5"
:class="{ 'mt-2': stats.length > 0 }"
>
<TooltipProvider v-if="subscribers">
<Tooltip>
<TooltipTrigger as-child>
<span
class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs"
>
<IconUsers class="size-3.5 text-muted-foreground" />
<span class="font-semibold tabular-nums">{{
formatNumberCompact(subscribers.value)
}}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ subscribers.label }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span
v-if="subscribers && reactions.length > 0"
class="mx-0.5 h-4 w-px bg-border"
aria-hidden="true"
/>
<span
v-for="reaction in reactions"
:key="reaction.label"
class="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-1 text-xs"
>
<span class="text-sm leading-none">{{ reaction.label }}</span>
<span class="font-semibold tabular-nums">{{
formatNumberCompact(reaction.value)
}}</span>
</span>
</div>
</div>
<!-- No data / unsupported: render nothing so the card stays clean. -->

View file

@ -9,6 +9,7 @@ import InstagramPreview from './InstagramPreview.vue';
import LinkedInPreview from './LinkedInPreview.vue';
import MastodonPreview from './MastodonPreview.vue';
import PinterestPreview from './PinterestPreview.vue';
import TelegramPreview from './TelegramPreview.vue';
import ThreadsPreview from './ThreadsPreview.vue';
import TikTokPreview from './TikTokPreview.vue';
import XPreview from './XPreview.vue';
@ -65,6 +66,8 @@ const previewComponent = computed(() => {
return BlueskyPreview;
case 'mastodon':
return MastodonPreview;
case 'telegram':
return TelegramPreview;
default:
return LinkedInPreview;
}

View file

@ -0,0 +1,160 @@
<script setup lang="ts">
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
}
defineProps<Props>();
// Mock reactions so the preview mirrors how a real Telegram channel post looks.
const sampleReactions = [
{ emoji: '❤️', count: 12, reacted: true },
{ emoji: '🔥', count: 7, reacted: false },
{ emoji: '👍', count: 4, reacted: false },
];
</script>
<template>
<div
class="flex h-full w-full flex-col overflow-hidden bg-[#a4bce0] dark:bg-[#0e1621]"
>
<!-- Header -->
<div
class="flex items-center gap-3 bg-white px-4 py-2.5 dark:bg-[#17212b]"
>
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-9 w-9 rounded-full object-cover"
/>
<div
v-else
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-[#2aabee] to-[#229ed9] font-semibold text-white"
>
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="min-w-0 flex-1">
<div
class="truncate text-[15px] font-semibold text-[#1f232b] dark:text-white"
>
{{ socialAccount.display_name || 'Channel' }}
</div>
<div class="text-[13px] text-[#707991] dark:text-[#708499]">
channel
</div>
</div>
</div>
<!-- Chat area -->
<div class="flex-1 overflow-y-auto px-3 py-4">
<div
class="max-w-[90%] overflow-hidden rounded-2xl rounded-bl-sm bg-white shadow-[0_1px_2px_rgba(0,0,0,0.12)] dark:bg-[#182533]"
>
<!-- Media -->
<div v-if="media.length > 0">
<div
class="overflow-hidden"
:class="{
'grid grid-cols-2 gap-0.5': media.length >= 2,
}"
>
<div
v-for="(item, index) in media.slice(0, 4)"
:key="item.id"
class="relative overflow-hidden"
:class="{
'aspect-[4/3]': media.length === 1,
'aspect-square': media.length > 1,
}"
>
<img
v-if="!isVideoMedia(item)"
:src="item.url"
:alt="item.original_filename"
class="h-full w-full object-cover"
/>
<VideoPreview
v-else
:src="item.url"
video-class="w-full h-full object-cover bg-black"
/>
<div
v-if="media.length > 4 && index === 3"
class="absolute inset-0 flex items-center justify-center bg-black/60"
>
<span class="text-xl font-semibold text-white"
>+{{ media.length - 4 }}</span
>
</div>
</div>
</div>
</div>
<!-- Body -->
<div class="px-3 py-2">
<div
v-if="content"
class="text-[15px] leading-[20px] whitespace-pre-wrap text-[#1f232b] dark:text-white"
>
{{ content }}
</div>
<!-- Reactions -->
<div class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="reaction in sampleReactions"
:key="reaction.emoji"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[13px] font-medium"
:class="
reaction.reacted
? 'bg-[#3390ec]/15 text-[#3390ec]'
: 'bg-black/[0.06] text-[#5a6570] dark:bg-white/10 dark:text-[#aeb9c4]'
"
>
<span class="text-sm leading-none">{{
reaction.emoji
}}</span>
<span class="tabular-nums">{{
reaction.count
}}</span>
</span>
</div>
<!-- Meta -->
<div
class="mt-1.5 flex items-center justify-end gap-1 text-[12px] text-[#707991] dark:text-[#708499]"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
<span>1.2K</span>
<span class="ml-1">4:30 PM</span>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,34 @@
import { onMounted, onUnmounted } from 'vue';
const POPUP_WIDTH = 600;
const POPUP_HEIGHT = 700;
/**
* Opens a platform's `/connect/{platform}` OAuth flow in a centered popup and
* invokes `onSuccess` when the popup posts back the `social-oauth-callback`
* message. The listener is wired to the calling component's lifecycle.
*/
export const useOAuthPopup = (onSuccess: () => void) => {
const openOAuthPopup = (platform: string) => {
const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
window.open(
`/connect/${platform}`,
'oauth-popup',
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`,
);
};
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'social-oauth-callback') return;
onSuccess();
};
onMounted(() => window.addEventListener('message', handleMessage));
onUnmounted(() => window.removeEventListener('message', handleMessage));
return { openOAuthPopup };
};

View file

@ -11,6 +11,7 @@ const PLATFORM_LOGOS: Record<string, string> = {
bluesky: '/images/accounts/bluesky.png',
pinterest: '/images/accounts/pinterest.png',
mastodon: '/images/accounts/mastodon.png',
telegram: '/images/accounts/telegram.png',
};
const PLATFORM_LABELS: Record<string, string> = {
@ -26,11 +27,16 @@ const PLATFORM_LABELS: Record<string, string> = {
bluesky: 'Bluesky',
pinterest: 'Pinterest',
mastodon: 'Mastodon',
telegram: 'Telegram',
};
const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
instagram: ['instagram_feed', 'instagram_reel', 'instagram_story'],
'instagram-facebook': ['instagram_feed', 'instagram_reel', 'instagram_story'],
'instagram-facebook': [
'instagram_feed',
'instagram_reel',
'instagram_story',
],
linkedin: ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
facebook: ['facebook_post', 'facebook_reel', 'facebook_story'],
@ -41,6 +47,7 @@ const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
pinterest: ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'],
bluesky: ['bluesky_post'],
mastodon: ['mastodon_post'],
telegram: ['telegram_post'],
};
export interface ContentTypeOption {

View file

@ -121,7 +121,7 @@ const handleToggle = (accountId: string) => {
const handleDisconnect = (account: SocialAccount) => {
deleteModal.value?.open({
url: disconnectAccount.url(account.id),
confirmText: account.username,
confirmText: account.username || account.display_name,
});
};
</script>

View file

@ -8,6 +8,7 @@ import FacebookAnalytics from '@/components/analytics/FacebookAnalytics.vue';
import InstagramAnalytics from '@/components/analytics/InstagramAnalytics.vue';
import LinkedInPageAnalytics from '@/components/analytics/LinkedInPageAnalytics.vue';
import PinterestAnalytics from '@/components/analytics/PinterestAnalytics.vue';
import TelegramAnalytics from '@/components/analytics/TelegramAnalytics.vue';
import ThreadsAnalytics from '@/components/analytics/ThreadsAnalytics.vue';
import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue';
import type { AnalyticsAccount } from '@/components/analytics/types';
@ -35,7 +36,16 @@ const selectedAccount = computed(() =>
const platformSupportsDateRange = computed(() => {
if (!selectedAccount.value) return false;
return ['instagram', 'instagram-facebook', 'facebook', 'youtube', 'pinterest', 'threads', 'x', 'linkedin-page'].includes(selectedAccount.value.platform);
return [
'instagram',
'instagram-facebook',
'facebook',
'youtube',
'pinterest',
'threads',
'x',
'linkedin-page',
].includes(selectedAccount.value.platform);
});
</script>
@ -43,7 +53,9 @@ const platformSupportsDateRange = computed(() => {
<AppLayout>
<Head :title="trans('sidebar.analytics')" />
<div class="mx-auto flex h-full w-full max-w-6xl flex-col gap-6 px-6 py-8">
<div
class="mx-auto flex h-full w-full max-w-6xl flex-col gap-6 px-6 py-8"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<PageHeader :title="$t('sidebar.analytics')" />
<div class="flex flex-wrap items-center gap-3">
@ -60,11 +72,17 @@ const platformSupportsDateRange = computed(() => {
</div>
</div>
<div v-if="accounts.length === 0" class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60">
<div
v-if="accounts.length === 0"
class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60"
>
{{ $t('analytics.no_accounts') }}
</div>
<div v-else-if="!selectedAccountId" class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60">
<div
v-else-if="!selectedAccountId"
class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60"
>
{{ $t('analytics.select_account') }}
</div>
@ -74,7 +92,10 @@ const platformSupportsDateRange = computed(() => {
/>
<InstagramAnalytics
v-else-if="selectedAccount?.platform === 'instagram' || selectedAccount?.platform === 'instagram-facebook'"
v-else-if="
selectedAccount?.platform === 'instagram' ||
selectedAccount?.platform === 'instagram-facebook'
"
:account-id="selectedAccountId"
:date-range="dateRange"
/>
@ -115,7 +136,15 @@ const platformSupportsDateRange = computed(() => {
:date-range="dateRange"
/>
<div v-else class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60">
<TelegramAnalytics
v-else-if="selectedAccount?.platform === 'telegram'"
:account-id="selectedAccountId"
/>
<div
v-else
class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60"
>
{{ $t('analytics.no_data') }}
</div>
</div>

View file

@ -19,6 +19,7 @@ export const ContentType = {
PinterestCarousel: 'pinterest_carousel',
BlueskyPost: 'bluesky_post',
MastodonPost: 'mastodon_post',
TelegramPost: 'telegram_post',
} as const;
export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType];

View file

@ -11,6 +11,7 @@ export const Platform = {
Pinterest: 'pinterest',
Bluesky: 'bluesky',
Mastodon: 'mastodon',
Telegram: 'telegram',
} as const;
export type PlatformValue = (typeof Platform)[keyof typeof Platform];

View file

@ -37,6 +37,7 @@
use App\Http\Controllers\Auth\MastodonController;
use App\Http\Controllers\Auth\PinterestController;
use App\Http\Controllers\Auth\SocialController;
use App\Http\Controllers\Auth\TelegramController;
use App\Http\Controllers\Auth\ThreadsController;
use App\Http\Controllers\Auth\TikTokController;
use App\Http\Controllers\Auth\XController;
@ -117,6 +118,8 @@
Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('app.social.mastodon.connect');
Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize');
Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('app.social.mastodon.callback');
Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect');
});
// Routes that require active subscription and completed onboarding

View file

@ -2,5 +2,6 @@
declare(strict_types=1);
require __DIR__.'/webhook.php';
require __DIR__.'/auth.php';
require __DIR__.'/app.php';

12
routes/webhook.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Webhooks\TelegramWebhookController;
use Illuminate\Support\Facades\Route;
Route::group([
'domain' => parse_url(config('app.webhook_url'), PHP_URL_HOST) ?: config('app.webhook_url'),
], function () {
Route::post('telegram/webhook', [TelegramWebhookController::class, 'handle'])->name('telegram.webhook');
});

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Actions\SocialAccount\RegisterTelegramWebhook;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config([
'trypost.platforms.telegram.bot_token' => 'TESTTOKEN',
'trypost.platforms.telegram.webhook_secret' => 'shh-secret',
]);
});
test('it registers the webhook with the url, secret and allowed updates', function () {
Http::fake([
'*/botTESTTOKEN/setWebhook' => Http::response(['ok' => true, 'result' => true], 200),
]);
$url = RegisterTelegramWebhook::execute();
expect($url)->toBe(route('telegram.webhook'));
Http::assertSent(function ($request) {
return str_contains($request->url(), '/botTESTTOKEN/setWebhook')
&& $request['url'] === route('telegram.webhook')
&& $request['secret_token'] === 'shh-secret'
&& $request['allowed_updates'] === ['message', 'channel_post', 'message_reaction_count'];
});
});
test('it throws when the bot token or secret is missing', function () {
config(['trypost.platforms.telegram.webhook_secret' => '']);
Http::fake();
expect(fn () => RegisterTelegramWebhook::execute())->toThrow(InvalidArgumentException::class);
Http::assertNothingSent();
});
test('it throws when telegram rejects the request', function () {
Http::fake([
'*/botTESTTOKEN/setWebhook' => Http::response(['ok' => false, 'description' => 'Unauthorized'], 401),
]);
expect(fn () => RegisterTelegramWebhook::execute())->toThrow(RuntimeException::class);
});

View file

@ -76,3 +76,33 @@
$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 &amp; Jerry & friends', Platform::Telegram);
expect($result)->toBe('Tom &amp; Jerry &amp; friends');
});
test('it drops anchors without an href for telegram', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>see <a>bare</a> and <a href="https://x.com">link</a></p>', Platform::Telegram);
expect($result)->toBe('see bare and <a href="https://x.com">link</a>');
});
test('it preserves @username mentions as plain text for telegram', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>Hey @durov and @TryPostBot</p>', Platform::Telegram);
expect($result)->toBe('Hey @durov and @TryPostBot');
});

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Telegram\TelegramAnalytics;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);
});
it('returns the channel subscriber count as an account metric', function () {
$account = SocialAccount::factory()->telegram()->create();
Http::fake([
'*/botTESTTOKEN/getChatMemberCount*' => Http::response(['ok' => true, 'result' => 1234], 200),
]);
expect(app(TelegramAnalytics::class)->getMetrics($account))
->toBe([['label' => 'Subscribers', 'value' => 1234]]);
});
it('returns no account metrics when the member count call fails', function () {
$account = SocialAccount::factory()->telegram()->create();
Http::fake([
'*/botTESTTOKEN/getChatMemberCount*' => Http::response(['ok' => false], 400),
]);
expect(app(TelegramAnalytics::class)->getMetrics($account))->toBe([]);
});
it('maps stored reactions to post metrics, tagged as reactions', function () {
config(['trypost.platforms.telegram.bot_token' => '']); // skip the subscriber lookup
$postPlatform = PostPlatform::factory()->create([
'platform' => Platform::Telegram,
'meta' => ['reactions' => [['type' => '👍', 'count' => 12], ['type' => '❤️', 'count' => 5]]],
]);
expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform))
->toBe([
['label' => '👍', 'value' => 12, 'kind' => 'reaction'],
['label' => '❤️', 'value' => 5, 'kind' => 'reaction'],
]);
});
it('includes the channel subscriber count alongside reactions', function () {
$account = SocialAccount::factory()->telegram()->create();
$postPlatform = PostPlatform::factory()->create([
'social_account_id' => $account->id,
'platform' => Platform::Telegram,
'meta' => ['reactions' => [['type' => '👍', 'count' => 3]]],
]);
Http::fake([
'*/botTESTTOKEN/getChatMemberCount*' => Http::response(['ok' => true, 'result' => 50], 200),
]);
expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform))
->toBe([
['label' => 'Subscribers', 'value' => 50, 'kind' => 'subscribers'],
['label' => '👍', 'value' => 3, 'kind' => 'reaction'],
]);
});
it('returns no post metrics when there are no reactions yet', function () {
config(['trypost.platforms.telegram.bot_token' => '']);
$postPlatform = PostPlatform::factory()->create([
'platform' => Platform::Telegram,
'meta' => [],
]);
expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform))->toBe([]);
});

View file

@ -0,0 +1,220 @@
<?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\Telegram\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 a single video', function () {
$this->post->update([
'content' => 'A clip',
'media' => [[
'id' => 'm1',
'path' => 'media/clip.mp4',
'url' => 'https://cdn.test/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
]],
]);
Http::fake([
'*/botTESTTOKEN/sendVideo' => Http::response(telegramOk(['message_id' => 8]), 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/sendVideo')
&& str_contains($request['video'], 'clip.mp4')
&& $request['caption'] === 'A clip';
});
});
test('telegram publisher sends a non-image, non-video file as a document', function () {
$this->post->update([
'content' => 'A file',
'media' => [[
'id' => 'm1',
'path' => 'media/report.pdf',
'url' => 'https://cdn.test/report.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'report.pdf',
]],
]);
Http::fake([
'*/botTESTTOKEN/sendDocument' => Http::response(telegramOk(['message_id' => 9]), 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/sendDocument')
&& str_contains($request['document'], 'report.pdf');
});
});
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');
});

View file

@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Events\TelegramChannelConnected;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\Telegram\TelegramConnectCode;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
config([
'trypost.platforms.telegram.bot_token' => 'TESTTOKEN',
'trypost.platforms.telegram.bot_username' => 'TryPostBot',
'trypost.platforms.telegram.webhook_secret' => 'shh-secret',
]);
$this->workspace = Workspace::factory()->create();
$this->user = User::factory()->create([
'current_workspace_id' => $this->workspace->id,
'account_id' => $this->workspace->account_id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
$this->user->refresh();
});
function telegramUpdate(string $code, array $chat = []): array
{
return [
'channel_post' => [
'message_id' => 5,
'chat' => array_merge([
'id' => -1001234567890,
'title' => 'My Channel',
'username' => 'mychannel',
'type' => 'channel',
], $chat),
'text' => "/connect {$code}",
],
];
}
it('issues a signed connect code carrying the workspace', function () {
$response = $this->actingAs($this->user)
->postJson(route('app.social.telegram.connect'))
->assertOk()
->assertJsonStructure(['code', 'nonce', 'bot_username', 'expires_at']);
expect($response->json('bot_username'))->toBe('TryPostBot');
expect(data_get(TelegramConnectCode::decode($response->json('code')), 'workspace_id'))
->toBe($this->workspace->id);
expect($response->json('nonce'))
->toBe(data_get(TelegramConnectCode::decode($response->json('code')), 'nonce'));
});
it('links the channel when the webhook receives a matching /connect', function () {
Http::fake();
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
$account = SocialAccount::where('workspace_id', $this->workspace->id)
->where('platform', Platform::Telegram)
->first();
expect($account)->not->toBeNull();
expect($account->platform_user_id)->toBe('-1001234567890');
expect($account->display_name)->toBe('My Channel');
expect($account->username)->toBe('mychannel');
expect(data_get($account->meta, 'chat_id'))->toBe('-1001234567890');
expect(data_get($account->meta, 'connect_nonce'))
->toBe(data_get(TelegramConnectCode::decode($code), 'nonce'));
});
it('stores the channel photo as the account avatar on connect', function () {
Storage::fake();
Http::fake([
'*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => ['photo' => ['big_file_id' => 'BIGFILE']]], 200),
'*/botTESTTOKEN/getFile*' => Http::response(['ok' => true, 'result' => ['file_path' => 'photos/file_1.jpg']], 200),
'*/file/botTESTTOKEN/photos/file_1.jpg' => Http::response('image-bytes', 200, ['Content-Type' => 'image/jpeg']),
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
$account = SocialAccount::where('platform', Platform::Telegram)->first();
expect($account->getRawOriginal('avatar_url'))->not->toBeNull();
});
it('links a private channel that has no username', function () {
Http::fake();
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code, ['username' => null]))
->assertNoContent();
$account = SocialAccount::where('platform', Platform::Telegram)->first();
expect($account->username)->toBeNull();
expect($account->display_name)->toBe('My Channel');
expect(data_get($account->meta, 'username'))->toBeNull();
});
it('does not create a new telegram account when the workspace is at its limit', function () {
config(['trypost.self_hosted' => false]);
SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(5);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
)->toBeFalse();
});
it('still reconnects an existing telegram channel even at the account limit', function () {
Http::fake();
config(['trypost.self_hosted' => false]);
SocialAccount::factory()->count(4)->create(['workspace_id' => $this->workspace->id]);
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '-1001234567890',
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(5);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->count()
)->toBe(1);
});
it('consumes the code once so it cannot be replayed for another chat', function () {
Http::fake();
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1001111111111]))
->assertNoContent();
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1002222222222]))
->assertNoContent();
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(1);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001111111111')->exists()
)->toBeTrue();
});
it('stores reaction counts on the matching post from a reaction update', function () {
$account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$postPlatform = PostPlatform::factory()->published()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => Platform::Telegram,
'platform_post_id' => '42',
]);
$update = [
'message_reaction_count' => [
'chat' => ['id' => -1001234567890],
'message_id' => 42,
'reactions' => [
['type' => ['type' => 'emoji', 'emoji' => '👍'], 'total_count' => 12],
['type' => ['type' => 'emoji', 'emoji' => '❤️'], 'total_count' => 5],
],
],
];
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), $update)
->assertNoContent();
expect(data_get($postPlatform->fresh()->meta, 'reactions'))
->toBe([['type' => '👍', 'count' => 12], ['type' => '❤️', 'count' => 5]]);
});
it('does not store reactions on a post from a different channel with the same message id', function () {
$otherAccount = SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '-1009999999999',
'meta' => ['chat_id' => '-1009999999999', 'username' => 'other', 'type' => 'channel'],
]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$postPlatform = PostPlatform::factory()->published()->create([
'post_id' => $post->id,
'social_account_id' => $otherAccount->id,
'platform' => Platform::Telegram,
'platform_post_id' => '42',
]);
$update = [
'message_reaction_count' => [
'chat' => ['id' => -1001234567890],
'message_id' => 42,
'reactions' => [['type' => ['type' => 'emoji', 'emoji' => '👍'], 'total_count' => 9]],
],
];
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), $update)
->assertNoContent();
expect(data_get($postPlatform->fresh()->meta, 'reactions'))->toBeNull();
});
it('labels custom emoji reactions with a fallback', function () {
$account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]);
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$postPlatform = PostPlatform::factory()->published()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => Platform::Telegram,
'platform_post_id' => '77',
]);
$update = [
'message_reaction_count' => [
'chat' => ['id' => -1001234567890],
'message_id' => 77,
'reactions' => [['type' => ['type' => 'custom_emoji', 'custom_emoji_id' => '555'], 'total_count' => 3]],
],
];
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), $update)
->assertNoContent();
expect(data_get($postPlatform->fresh()->meta, 'reactions'))
->toBe([['type' => 'Custom', 'count' => 3]]);
});
it('connects without an avatar when the channel has no photo', function () {
Http::fake([
'*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => []], 200),
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
$account = SocialAccount::where('platform', Platform::Telegram)->first();
expect($account)->not->toBeNull();
expect($account->getRawOriginal('avatar_url'))->toBeNull();
});
it('rejects the webhook without the secret token', function () {
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertForbidden();
});
it('ignores the webhook for a tampered or expired code', function () {
$expired = TelegramConnectCode::issue($this->workspace->id, now()->subMinute());
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($expired))
->assertNoContent();
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('not-a-valid-code'))
->assertNoContent();
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0);
});
it('broadcasts to the workspace with the nonce when a channel connects', function () {
Event::fake([TelegramChannelConnected::class]);
Http::fake();
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$nonce = data_get(TelegramConnectCode::decode($code), 'nonce');
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
Event::assertDispatched(
TelegramChannelConnected::class,
fn (TelegramChannelConnected $event) => $event->workspaceId === $this->workspace->id
&& $event->nonce === $nonce,
);
});
it('does not broadcast when the code is tampered or already used', function () {
Event::fake([TelegramChannelConnected::class]);
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('not-a-valid-code'))
->assertNoContent();
Event::assertNotDispatched(TelegramChannelConnected::class);
});
it('verifies a connected telegram account via getChat', function () {
config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);
$account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]);
Http::fake([
'*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => ['id' => -1001234567890]], 200),
]);
expect(app(ConnectionVerifier::class)->verify($account))->toBeTrue();
});
it('reports a telegram account as invalid when getChat fails', function () {
config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);
$account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]);
Http::fake([
'*/botTESTTOKEN/getChat*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400),
]);
expect(app(ConnectionVerifier::class)->verify($account))->toBeFalse();
});
it('registers the webhook via the artisan command', function () {
Http::fake([
'*/botTESTTOKEN/setWebhook' => Http::response(['ok' => true, 'result' => true], 200),
]);
$this->artisan('telegram:set-webhook')->assertSuccessful();
Http::assertSent(function ($request) {
return str_contains($request->url(), '/setWebhook')
&& $request['secret_token'] === 'shh-secret'
&& str_contains($request['url'], 'telegram/webhook');
});
});

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TelegramPublishException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
function telegramErrorResponse(array $body, int $status): Response
{
return Http::fake(['*' => Http::response($body, $status)])->post('https://api.telegram.org/botX/sendMessage');
}
test('HTTP 403 maps to Permission category', function () {
$exception = TelegramPublishException::fromApiResponse(
telegramErrorResponse(['ok' => false, 'description' => 'Forbidden'], 403),
);
expect($exception->category)->toBe(ErrorCategory::Permission)
->and($exception->platformErrorCode)->toBe('403');
});
test('HTTP 401 maps to Permission category', function () {
$exception = TelegramPublishException::fromApiResponse(
telegramErrorResponse(['ok' => false, 'description' => 'Unauthorized'], 401),
);
expect($exception->category)->toBe(ErrorCategory::Permission)
->and($exception->platformErrorCode)->toBe('401');
});
test('HTTP 429 maps to RateLimit category', function () {
$exception = TelegramPublishException::fromApiResponse(
telegramErrorResponse(['ok' => false, 'description' => 'Too Many Requests'], 429),
);
expect($exception->category)->toBe(ErrorCategory::RateLimit);
});
test('HTTP 500 maps to ServerError category', function () {
$exception = TelegramPublishException::fromApiResponse(
telegramErrorResponse(['ok' => false, 'description' => 'Internal'], 500),
);
expect($exception->category)->toBe(ErrorCategory::ServerError);
});
test('other errors map to Unknown category with the api description', function () {
$exception = TelegramPublishException::fromApiResponse(
telegramErrorResponse(['ok' => false, 'description' => 'Bad Request: chat not found'], 400),
);
expect($exception->category)->toBe(ErrorCategory::Unknown)
->and($exception->userMessage)->toBe('Bad Request: chat not found');
});
test('platform returns telegram', function () {
$exception = TelegramPublishException::fromApiResponse(
telegramErrorResponse(['ok' => false, 'description' => 'Error'], 400),
);
expect($exception->platform())->toBe('telegram');
});

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Services\Social\Telegram\TelegramApi;
beforeEach(function () {
config([
'trypost.platforms.telegram.bot_token' => 'TESTTOKEN',
'trypost.platforms.telegram.api' => 'https://api.telegram.org',
]);
});
it('builds a method endpoint from the token and host', function () {
expect(TelegramApi::endpoint('sendMessage'))
->toBe('https://api.telegram.org/botTESTTOKEN/sendMessage');
});
it('builds a file download url', function () {
expect(TelegramApi::fileUrl('photos/file_1.jpg'))
->toBe('https://api.telegram.org/file/botTESTTOKEN/photos/file_1.jpg');
});
it('trims a trailing slash from the configured host', function () {
config(['trypost.platforms.telegram.api' => 'https://api.telegram.org/']);
expect(TelegramApi::endpoint('getChat'))
->toBe('https://api.telegram.org/botTESTTOKEN/getChat');
});