Merge pull request #95 from trypostit/feat/telegram-channel
Add Telegram publishing channel
This commit is contained in:
commit
11c27dfd62
61 changed files with 2845 additions and 206 deletions
11
.env.example
11
.env.example
|
|
@ -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=
|
||||
|
|
|
|||
116
app/Actions/SocialAccount/ConnectTelegramChannel.php
Normal file
116
app/Actions/SocialAccount/ConnectTelegramChannel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
app/Actions/SocialAccount/RegisterTelegramWebhook.php
Normal file
44
app/Actions/SocialAccount/RegisterTelegramWebhook.php
Normal 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;
|
||||
}
|
||||
}
|
||||
49
app/Actions/SocialAccount/StoreTelegramReactions.php
Normal file
49
app/Actions/SocialAccount/StoreTelegramReactions.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
31
app/Console/Commands/Telegram/SetWebhook.php
Normal file
31
app/Console/Commands/Telegram/SetWebhook.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,9 @@ enum ContentType: string
|
|||
// Mastodon
|
||||
case MastodonPost = 'mastodon_post';
|
||||
|
||||
// Telegram
|
||||
case TelegramPost = 'telegram_post';
|
||||
|
||||
/**
|
||||
* AI generation format for an Instagram carousel. Not a content type —
|
||||
* carousel posts are persisted as InstagramFeed.
|
||||
|
|
@ -77,6 +80,7 @@ public function label(): string
|
|||
self::PinterestCarousel => 'Carousel',
|
||||
self::BlueskyPost => 'Post',
|
||||
self::MastodonPost => 'Post',
|
||||
self::TelegramPost => 'Post',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +103,7 @@ public function platform(): SocialPlatform
|
|||
self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest,
|
||||
self::BlueskyPost => SocialPlatform::Bluesky,
|
||||
self::MastodonPost => SocialPlatform::Mastodon,
|
||||
self::TelegramPost => SocialPlatform::Telegram,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +172,7 @@ public function maxMediaCount(): int
|
|||
self::PinterestCarousel => 5,
|
||||
self::BlueskyPost => 4,
|
||||
self::MastodonPost => 4,
|
||||
self::TelegramPost => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +192,7 @@ public function supportsVideo(): bool
|
|||
self::PinterestPin, self::PinterestCarousel => false,
|
||||
self::BlueskyPost => true,
|
||||
self::MastodonPost => true,
|
||||
self::TelegramPost => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +229,7 @@ public function requiresMedia(): bool
|
|||
self::ThreadsPost => false,
|
||||
self::BlueskyPost => false,
|
||||
self::MastodonPost => false,
|
||||
self::TelegramPost => false,
|
||||
self::FacebookPost => false,
|
||||
self::InstagramFeed => false,
|
||||
default => true,
|
||||
|
|
@ -304,6 +312,7 @@ public static function defaultFor(SocialPlatform $platform): self
|
|||
SocialPlatform::Pinterest => self::PinterestPin,
|
||||
SocialPlatform::Bluesky => self::BlueskyPost,
|
||||
SocialPlatform::Mastodon => self::MastodonPost,
|
||||
SocialPlatform::Telegram => self::TelegramPost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ enum Platform: string
|
|||
case Pinterest = 'pinterest';
|
||||
case Bluesky = 'bluesky';
|
||||
case Mastodon = 'mastodon';
|
||||
case Telegram = 'telegram';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -36,6 +37,7 @@ public function label(): string
|
|||
self::Pinterest => 'Pinterest',
|
||||
self::Bluesky => 'Bluesky',
|
||||
self::Mastodon => 'Mastodon',
|
||||
self::Telegram => 'Telegram',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ public function color(): string
|
|||
self::Pinterest => '#E60023',
|
||||
self::Bluesky => '#0085FF',
|
||||
self::Mastodon => '#6364FF',
|
||||
self::Telegram => '#26A5E4',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +72,7 @@ public function allowedMediaTypes(): array
|
|||
self::Pinterest => [MediaType::Image, MediaType::Video],
|
||||
self::Bluesky => [MediaType::Image, MediaType::Video],
|
||||
self::Mastodon => [MediaType::Image, MediaType::Video],
|
||||
self::Telegram => [MediaType::Image, MediaType::Video],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +89,7 @@ public function maxImages(): int
|
|||
self::Pinterest => 5,
|
||||
self::Bluesky => 4,
|
||||
self::Mastodon => 4,
|
||||
self::Telegram => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +112,8 @@ public function maxImages(): int
|
|||
* - Pinterest pin description: 800 (title is 100, not modeled here)
|
||||
* - Bluesky: 300 graphemes
|
||||
* - Mastodon: 500 default; instances may be higher (we stay conservative)
|
||||
* - Telegram: 4096 for a text message (media captions are capped at 1024,
|
||||
* handled in the publisher by sending long text as its own message)
|
||||
*/
|
||||
public function maxContentLength(): int
|
||||
{
|
||||
|
|
@ -121,6 +128,7 @@ public function maxContentLength(): int
|
|||
self::Pinterest => 800,
|
||||
self::Bluesky => 300,
|
||||
self::Mastodon => 500,
|
||||
self::Telegram => 4096,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +170,8 @@ public function recommendedAiContentLength(): int
|
|||
// YouTube Shorts — fits within the 100-char title (with " #Shorts"
|
||||
// suffix taking 8 chars) so the same string works as title + desc
|
||||
self::YouTube => 80,
|
||||
// Telegram channel posts — short announcements read best
|
||||
self::Telegram => 400,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +193,7 @@ public function requiredPublishScopes(): array
|
|||
self::Pinterest => ['pins:write'],
|
||||
self::Bluesky => [],
|
||||
self::Mastodon => ['write:statuses'],
|
||||
self::Telegram => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +210,7 @@ public function supportsTextOnly(): bool
|
|||
self::Pinterest => false,
|
||||
self::Bluesky => true,
|
||||
self::Mastodon => true,
|
||||
self::Telegram => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
45
app/Events/TelegramChannelConnected.php
Normal file
45
app/Events/TelegramChannelConnected.php
Normal 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';
|
||||
}
|
||||
}
|
||||
68
app/Exceptions/Social/TelegramPublishException.php
Normal file
68
app/Exceptions/Social/TelegramPublishException.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class TelegramPublishException extends SocialPublishException
|
||||
{
|
||||
public static function fromApiResponse(mixed $response): static
|
||||
{
|
||||
/** @var Response $response */
|
||||
$status = $response->status();
|
||||
$rawResponse = $response->body();
|
||||
$description = (string) data_get($response->json(), 'description', 'An unknown Telegram error occurred.');
|
||||
|
||||
// 403: the bot was removed or isn't an admin of the channel anymore.
|
||||
if ($status === 403) {
|
||||
return new static(
|
||||
userMessage: 'The bot is not an admin of this channel. Re-add it as an administrator and try again.',
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
// 401: the configured bot token is invalid (operator-level misconfiguration).
|
||||
if ($status === 401) {
|
||||
return new static(
|
||||
userMessage: 'Telegram rejected the bot token. Check the TELEGRAM_BOT_TOKEN configuration.',
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 429) {
|
||||
return new static(
|
||||
userMessage: 'Telegram rate limit reached. Please try again shortly.',
|
||||
category: ErrorCategory::RateLimit,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if ($status >= 500) {
|
||||
return new static(
|
||||
userMessage: 'Telegram is temporarily unavailable. Please try again later.',
|
||||
category: ErrorCategory::ServerError,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
return new static(
|
||||
userMessage: $description,
|
||||
category: ErrorCategory::Unknown,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public function platform(): string
|
||||
{
|
||||
return 'telegram';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 => [],
|
||||
};
|
||||
|
||||
|
|
|
|||
43
app/Http/Controllers/Auth/TelegramController.php
Normal file
43
app/Http/Controllers/Auth/TelegramController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/Webhooks/TelegramWebhookController.php
Normal file
56
app/Http/Controllers/Webhooks/TelegramWebhookController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ protected function profileUrl(): Attribute
|
|||
SocialPlatform::Mastodon => ($username && data_get($this->meta, 'instance'))
|
||||
? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}"
|
||||
: null,
|
||||
SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null,
|
||||
default => null,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -196,6 +196,12 @@ private function getImageConfig(Platform $platform): array
|
|||
'format' => 'image/jpeg',
|
||||
'quality' => 100,
|
||||
],
|
||||
Platform::Telegram => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 10 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 100,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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+);)/', '&', $content);
|
||||
|
||||
$content = preg_replace("/\n{3,}/", "\n\n", $content);
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function stripHtml(string $content): string
|
||||
{
|
||||
// Convert <p> tags to newlines
|
||||
|
|
|
|||
72
app/Services/Social/Telegram/TelegramAnalytics.php
Normal file
72
app/Services/Social/Telegram/TelegramAnalytics.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
app/Services/Social/Telegram/TelegramApi.php
Normal file
44
app/Services/Social/Telegram/TelegramApi.php
Normal 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'), '/');
|
||||
}
|
||||
}
|
||||
58
app/Services/Social/Telegram/TelegramConnectCode.php
Normal file
58
app/Services/Social/Telegram/TelegramConnectCode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
39
app/Services/Social/Telegram/TelegramMediaType.php
Normal file
39
app/Services/Social/Telegram/TelegramMediaType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
162
app/Services/Social/Telegram/TelegramPublisher.php
Normal file
162
app/Services/Social/Telegram/TelegramPublisher.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
|
||||
$middleware->preventRequestForgery(except: [
|
||||
'stripe/*',
|
||||
'telegram/webhook',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -153,6 +153,15 @@
|
|||
// Default instance used when the account has no `meta.instance` override.
|
||||
'default_instance' => env('MASTODON_DEFAULT_INSTANCE', 'https://mastodon.social'),
|
||||
],
|
||||
'telegram' => [
|
||||
'enabled' => env('TELEGRAM_ENABLED', true),
|
||||
// Single shared bot (BotFather). Users add it as admin to their channel.
|
||||
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
|
||||
'bot_username' => env('TELEGRAM_BOT_USERNAME'),
|
||||
'api' => env('TELEGRAM_API', 'https://api.telegram.org'),
|
||||
// Secret-token header Telegram echoes on every webhook call.
|
||||
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -137,6 +137,23 @@ public function mastodon(): static
|
|||
]);
|
||||
}
|
||||
|
||||
public function telegram(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Telegram,
|
||||
'scopes' => Platform::Telegram->requiredPublishScopes(),
|
||||
'token_expires_at' => null, // the shared bot token never expires
|
||||
'access_token' => '',
|
||||
'refresh_token' => '',
|
||||
'username' => 'mychannel',
|
||||
'meta' => [
|
||||
'chat_id' => '-1001234567890',
|
||||
'username' => 'mychannel',
|
||||
'type' => 'channel',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function disconnected(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
BIN
public/images/accounts/telegram.png
Normal file
BIN
public/images/accounts/telegram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
243
resources/js/components/accounts/TelegramConnectDialog.vue
Normal file
243
resources/js/components/accounts/TelegramConnectDialog.vue
Normal 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>
|
||||
57
resources/js/components/analytics/TelegramAnalytics.vue
Normal file
57
resources/js/components/analytics/TelegramAnalytics.vue
Normal 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>
|
||||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
160
resources/js/components/posts/previews/TelegramPreview.vue
Normal file
160
resources/js/components/posts/previews/TelegramPreview.vue
Normal 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>
|
||||
34
resources/js/composables/useOAuthPopup.ts
Normal file
34
resources/js/composables/useOAuthPopup.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
12
routes/webhook.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 & Jerry & friends', Platform::Telegram);
|
||||
expect($result)->toBe('Tom & Jerry & 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');
|
||||
});
|
||||
|
|
|
|||
79
tests/Feature/Services/Social/TelegramAnalyticsTest.php
Normal file
79
tests/Feature/Services/Social/TelegramAnalyticsTest.php
Normal 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([]);
|
||||
});
|
||||
220
tests/Feature/Services/Social/TelegramPublisherTest.php
Normal file
220
tests/Feature/Services/Social/TelegramPublisherTest.php
Normal 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');
|
||||
});
|
||||
366
tests/Feature/Social/TelegramConnectionTest.php
Normal file
366
tests/Feature/Social/TelegramConnectionTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
29
tests/Unit/Services/Social/TelegramApiTest.php
Normal file
29
tests/Unit/Services/Social/TelegramApiTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue