From a4cf8aa4cef7eb1260ddfb27bf6e6ab9a0df3898 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 21:39:03 -0300 Subject: [PATCH] Add Telegram connection flow (controller + webhook) Connect a channel by issuing a one-time code the user posts as /connect in their channel. A secret-token-guarded webhook matches the code, links the channel as a SocialAccount (chat_id in meta), and records it on the request so the connect endpoint can poll for completion. Adds the TelegramConnectRequest model + migration, the connect/status endpoints, the public webhook route (CSRF exempt), a ConnectionVerifier branch (getChat liveness), and a telegram:set-webhook command. Tests cover the code issue, webhook link, secret rejection, expired/ unknown codes, status polling, and the command. --- app/Console/Commands/Telegram/SetWebhook.php | 46 ++++++ .../Controllers/Auth/TelegramController.php | 68 +++++++++ .../Webhooks/TelegramWebhookController.php | 79 ++++++++++ app/Models/TelegramConnectRequest.php | 40 +++++ app/Services/Social/ConnectionVerifier.php | 14 ++ bootstrap/app.php | 1 + ...create_telegram_connect_requests_table.php | 29 ++++ routes/app.php | 4 + routes/web.php | 5 + .../Feature/Social/TelegramConnectionTest.php | 143 ++++++++++++++++++ 10 files changed, 429 insertions(+) create mode 100644 app/Console/Commands/Telegram/SetWebhook.php create mode 100644 app/Http/Controllers/Auth/TelegramController.php create mode 100644 app/Http/Controllers/Webhooks/TelegramWebhookController.php create mode 100644 app/Models/TelegramConnectRequest.php create mode 100644 database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php create mode 100644 tests/Feature/Social/TelegramConnectionTest.php diff --git a/app/Console/Commands/Telegram/SetWebhook.php b/app/Console/Commands/Telegram/SetWebhook.php new file mode 100644 index 00000000..33a77dea --- /dev/null +++ b/app/Console/Commands/Telegram/SetWebhook.php @@ -0,0 +1,46 @@ +error('TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET must both be set.'); + + return self::FAILURE; + } + + $url = route('telegram.webhook'); + + $response = Http::post("{$api}/bot{$token}/setWebhook", [ + 'url' => $url, + 'secret_token' => $secret, + 'allowed_updates' => ['message', 'channel_post'], + ]); + + if (! $response->successful() || data_get($response->json(), 'ok') !== true) { + $this->error('Failed to set webhook: '.$response->body()); + + return self::FAILURE; + } + + $this->info("Telegram webhook registered at {$url}"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php new file mode 100644 index 00000000..fb146860 --- /dev/null +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -0,0 +1,68 @@ +`) so the webhook can tie the channel to this workspace. + */ + 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); + + $connectRequest = TelegramConnectRequest::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $request->user()->id, + 'code' => Str::lower(Str::random(12)), + 'expires_at' => now()->addMinutes(15), + ]); + + return response()->json([ + 'code' => $connectRequest->code, + 'bot_username' => config('trypost.platforms.telegram.bot_username'), + 'expires_at' => $connectRequest->expires_at->toIso8601String(), + ]); + } + + /** + * Poll whether the channel has been linked yet. + */ + public function status(Request $request): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.'); + + $connectRequest = TelegramConnectRequest::query() + ->where('workspace_id', $workspace->id) + ->where('code', (string) $request->query('code')) + ->first(); + + $status = match (true) { + $connectRequest === null => 'unknown', + $connectRequest->social_account_id !== null => 'connected', + $connectRequest->isExpired() => 'expired', + default => 'pending', + }; + + return response()->json(['status' => $status]); + } +} diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php new file mode 100644 index 00000000..c394dade --- /dev/null +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -0,0 +1,79 @@ +` + * message/channel_post: it ties the originating channel to the workspace that + * generated the code. 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(); + $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(); + } + + $connectRequest = TelegramConnectRequest::query() + ->whereNull('social_account_id') + ->where('code', $matches[1]) + ->where('expires_at', '>', now()) + ->first(); + + if ($connectRequest === null) { + return response()->noContent(); + } + + $chatId = (string) data_get($chat, 'id'); + $username = data_get($chat, 'username'); + + $account = $connectRequest->workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => SocialPlatform::Telegram->value, + 'platform_user_id' => $chatId, + ], + [ + 'username' => $username, + 'display_name' => data_get($chat, 'title') ?? $username, + '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'), + ], + ], + ); + + $connectRequest->update(['social_account_id' => $account->id]); + + return response()->noContent(); + } +} diff --git a/app/Models/TelegramConnectRequest.php b/app/Models/TelegramConnectRequest.php new file mode 100644 index 00000000..5f44a283 --- /dev/null +++ b/app/Models/TelegramConnectRequest.php @@ -0,0 +1,40 @@ + 'datetime', + ]; + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 74cd90e8..4fb3da56 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -66,6 +66,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 +505,19 @@ private function verifyBluesky(SocialAccount $account): bool return $response->successful(); } + private function verifyTelegram(SocialAccount $account): bool + { + $token = (string) config('trypost.platforms.telegram.bot_token'); + $api = rtrim((string) config('trypost.platforms.telegram.api'), '/'); + + // getChat succeeds only while the bot can still reach the chat. + $response = Http::get("{$api}/bot{$token}/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'); diff --git a/bootstrap/app.php b/bootstrap/app.php index 996308de..abff5dd7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -40,6 +40,7 @@ $middleware->preventRequestForgery(except: [ 'stripe/*', + 'telegram/webhook', ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php b/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php new file mode 100644 index 00000000..ed34310a --- /dev/null +++ b/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php @@ -0,0 +1,29 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('code')->unique(); + // Set by the webhook once the channel is linked; null while pending. + $table->foreignUuid('social_account_id')->nullable()->constrained('social_accounts')->nullOnDelete(); + $table->timestamp('expires_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('telegram_connect_requests'); + } +}; diff --git a/routes/app.php b/routes/app.php index fe17fe82..f20c6a33 100644 --- a/routes/app.php +++ b/routes/app.php @@ -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,9 @@ 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'); + Route::get('connect/telegram/status', [TelegramController::class, 'status'])->name('app.social.telegram.status'); }); // Routes that require active subscription and completed onboarding diff --git a/routes/web.php b/routes/web.php index b2f144b6..77575c38 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,5 +2,10 @@ declare(strict_types=1); +use App\Http\Controllers\Webhooks\TelegramWebhookController; +use Illuminate\Support\Facades\Route; + +Route::post('telegram/webhook', [TelegramWebhookController::class, 'handle'])->name('telegram.webhook'); + require __DIR__.'/auth.php'; require __DIR__.'/app.php'; diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php new file mode 100644 index 00000000..489d6b76 --- /dev/null +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -0,0 +1,143 @@ + '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 connect code', function () { + $response = $this->actingAs($this->user) + ->postJson(route('app.social.telegram.connect')) + ->assertOk() + ->assertJsonStructure(['code', 'bot_username', 'expires_at']); + + expect($response->json('bot_username'))->toBe('TryPostBot'); + + $this->assertDatabaseHas('telegram_connect_requests', [ + 'workspace_id' => $this->workspace->id, + 'code' => $response->json('code'), + 'social_account_id' => null, + ]); +}); + +it('links the channel when the webhook receives a matching /connect', function () { + $request = TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'abc123code', + 'expires_at' => now()->addMinutes(15), + ]); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('abc123code')) + ->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($request->fresh()->social_account_id)->toBe($account->id); +}); + +it('rejects the webhook without the secret token', function () { + $this->postJson(route('telegram.webhook'), telegramUpdate('whatever')) + ->assertForbidden(); +}); + +it('ignores the webhook for an unknown or expired code', function () { + TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'expiredcode', + 'expires_at' => now()->subMinute(), + ]); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('expiredcode')) + ->assertNoContent(); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('does-not-exist')) + ->assertNoContent(); + + expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0); +}); + +it('reports connection status while pending and once connected', function () { + $request = TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'statuscode', + 'expires_at' => now()->addMinutes(15), + ]); + + $this->actingAs($this->user) + ->getJson(route('app.social.telegram.status', ['code' => 'statuscode'])) + ->assertOk() + ->assertJson(['status' => 'pending']); + + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + $request->update(['social_account_id' => $account->id]); + + $this->actingAs($this->user) + ->getJson(route('app.social.telegram.status', ['code' => 'statuscode'])) + ->assertOk() + ->assertJson(['status' => 'connected']); +}); + +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'); + }); +});