Add Telegram connection flow (controller + webhook)
Connect a channel by issuing a one-time code the user posts as /connect <code> 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.
This commit is contained in:
parent
9634e88e5d
commit
a4cf8aa4ce
10 changed files with 429 additions and 0 deletions
46
app/Console/Commands/Telegram/SetWebhook.php
Normal file
46
app/Console/Commands/Telegram/SetWebhook.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Telegram;
|
||||
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
#[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
|
||||
{
|
||||
$token = (string) config('trypost.platforms.telegram.bot_token');
|
||||
$api = rtrim((string) config('trypost.platforms.telegram.api'), '/');
|
||||
$secret = (string) config('trypost.platforms.telegram.webhook_secret');
|
||||
|
||||
if ($token === '' || $secret === '') {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/Auth/TelegramController.php
Normal file
68
app/Http/Controllers/Auth/TelegramController.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Models\TelegramConnectRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class TelegramController extends SocialController
|
||||
{
|
||||
protected SocialPlatform $platform = SocialPlatform::Telegram;
|
||||
|
||||
/**
|
||||
* Start a connection: issue a one-time code the user posts in their channel
|
||||
* (`/connect <code>`) 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]);
|
||||
}
|
||||
}
|
||||
79
app/Http/Controllers/Webhooks/TelegramWebhookController.php
Normal file
79
app/Http/Controllers/Webhooks/TelegramWebhookController.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Webhooks;
|
||||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TelegramConnectRequest;
|
||||
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: 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();
|
||||
}
|
||||
}
|
||||
40
app/Models/TelegramConnectRequest.php
Normal file
40
app/Models/TelegramConnectRequest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TelegramConnectRequest extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
|
||||
$middleware->preventRequestForgery(except: [
|
||||
'stripe/*',
|
||||
'telegram/webhook',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('telegram_connect_requests', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
143
tests/Feature/Social/TelegramConnectionTest.php
Normal file
143
tests/Feature/Social/TelegramConnectionTest.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\TelegramConnectRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue