Detect Telegram connection via broadcast instead of polling

This commit is contained in:
Paulo Castellano 2026-06-14 10:58:13 -03:00
parent f5d6c4a186
commit ce5d59a149
8 changed files with 117 additions and 132 deletions

View file

@ -6,6 +6,7 @@
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;
@ -43,7 +44,7 @@ public static function execute(Workspace $workspace, array $chat, string $nonce)
return null;
}
return $workspace->socialAccounts()->updateOrCreate(
$account = $workspace->socialAccounts()->updateOrCreate(
[
'platform' => Platform::Telegram->value,
'platform_user_id' => $chatId,
@ -67,6 +68,10 @@ public static function execute(Workspace $workspace, array $chat, string $nonce)
],
],
);
TelegramChannelConnected::dispatch($workspace->id, $nonce);
return $account;
}
/**

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums\SocialAccount;
use App\Models\SocialAccount;
enum TelegramConnectStatus: string
{
case Unknown = 'unknown';
case Pending = 'pending';
case Connected = 'connected';
/**
* Connected once the channel has been linked to an account; otherwise still pending.
*/
public static function for(?SocialAccount $account): self
{
return $account === null ? self::Pending : self::Connected;
}
}

View file

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

View file

@ -5,7 +5,6 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\TelegramConnectStatus;
use App\Services\Social\TelegramConnectCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -18,7 +17,8 @@ class TelegramController extends SocialController
/**
* 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.
* 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
{
@ -31,35 +31,13 @@ public function connect(Request $request): JsonResponse
$this->ensureSocialAccountLimit($workspace);
$expiresAt = now()->addMinutes(15);
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);
return response()->json([
'code' => TelegramConnectCode::issue($workspace->id, $expiresAt),
'code' => $code,
'nonce' => data_get(TelegramConnectCode::decode($code), 'nonce'),
'bot_username' => config('trypost.platforms.telegram.bot_username'),
'expires_at' => $expiresAt->toIso8601String(),
]);
}
/**
* Poll whether the channel for the given code has been linked yet.
*/
public function status(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
$payload = TelegramConnectCode::decode($request->query('code'));
if ($payload === null) {
return response()->json(['status' => TelegramConnectStatus::Unknown->value]);
}
$account = $workspace->socialAccounts()
->where('platform', SocialPlatform::Telegram->value)
->where('meta->connect_nonce', data_get($payload, 'nonce'))
->first();
return response()->json([
'status' => TelegramConnectStatus::for($account)->value,
]);
}
}

View file

@ -20,15 +20,10 @@ import {
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,
status as telegramStatus,
} from '@/routes/app/social/telegram';
import {
TelegramConnectStatus,
type TelegramConnectStatusValue,
} from '@/types/telegram-connect-status';
import { connect as connectTelegram } from '@/routes/app/social/telegram';
const open = defineModel<boolean>('open', { required: true });
@ -36,64 +31,47 @@ type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error';
interface ConnectResponse {
code: string;
nonce: string;
bot_username: string;
expires_at: string;
}
const POLL_INTERVAL_MS = 3000;
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>({});
const httpStatus = useHttp<
Record<string, never>,
{ status: TelegramConnectStatusValue }
>({});
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
const stopPolling = () => {
if (pollTimer !== null) {
clearTimeout(pollTimer);
pollTimer = null;
const clearExpiry = () => {
if (expiryTimer !== null) {
clearTimeout(expiryTimer);
expiryTimer = null;
}
};
const poll = async () => {
if (phase.value !== 'ready') return;
try {
const response = await httpStatus.get(
telegramStatus.url({ query: { code: code.value } }),
);
if (response?.status === TelegramConnectStatus.Connected) {
phase.value = 'connected';
stopPolling();
toast.success(trans('accounts.telegram.connected_toast'));
setTimeout(() => {
open.value = false;
router.reload();
}, SUCCESS_CLOSE_DELAY_MS);
// 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;
}
// The signed code expired (or the session was lost): prompt a fresh one.
if (response?.status === TelegramConnectStatus.Unknown) {
phase.value = 'expired';
stopPolling();
return;
}
} catch {
// Transient polling failures are ignored; the next tick retries.
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
};
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';
@ -102,9 +80,19 @@ const start = async () => {
try {
const response = await httpConnect.post(connectTelegram.url());
code.value = response.code;
nonce.value = response.nonce;
botUsername.value = response.bot_username;
phase.value = 'ready';
poll();
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 =
@ -124,11 +112,11 @@ watch(open, (isOpen) => {
if (isOpen) {
start();
} else {
stopPolling();
clearExpiry();
}
});
onUnmounted(stopPolling);
onUnmounted(clearExpiry);
</script>
<template>

View file

@ -1,8 +0,0 @@
export const TelegramConnectStatus = {
Unknown: 'unknown',
Pending: 'pending',
Connected: 'connected',
} as const;
export type TelegramConnectStatusValue =
(typeof TelegramConnectStatus)[keyof typeof TelegramConnectStatus];

View file

@ -120,7 +120,6 @@
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

View file

@ -4,11 +4,13 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Events\TelegramChannelConnected;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\TelegramConnectCode;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
@ -48,11 +50,13 @@ function telegramUpdate(string $code, array $chat = []): array
$response = $this->actingAs($this->user)
->postJson(route('app.social.telegram.connect'))
->assertOk()
->assertJsonStructure(['code', 'bot_username', 'expires_at']);
->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 () {
@ -189,36 +193,32 @@ function telegramUpdate(string $code, array $chat = []): array
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0);
});
it('reports the connection status for a code while pending and once connected', function () {
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->actingAs($this->user)
->getJson(route('app.social.telegram.status', ['code' => $code]))
->assertOk()
->assertJson(['status' => 'pending']);
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'meta' => ['chat_id' => '-1001234567890', 'username' => 'mychannel', 'type' => 'channel', 'connect_nonce' => $nonce],
]);
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status', ['code' => $code]))
->assertOk()
->assertJson(['status' => 'connected']);
Event::assertDispatched(
TelegramChannelConnected::class,
fn (TelegramChannelConnected $event) => $event->workspaceId === $this->workspace->id
&& $event->nonce === $nonce,
);
});
it('reports unknown status without a valid code', function () {
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status'))
->assertOk()
->assertJson(['status' => 'unknown']);
it('does not broadcast when the code is tampered or already used', function () {
Event::fake([TelegramChannelConnected::class]);
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status', ['code' => 'tampered']))
->assertOk()
->assertJson(['status' => 'unknown']);
$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 () {