Detect Telegram connection via broadcast instead of polling
This commit is contained in:
parent
f5d6c4a186
commit
ce5d59a149
8 changed files with 117 additions and 132 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
export const TelegramConnectStatus = {
|
||||
Unknown: 'unknown',
|
||||
Pending: 'pending',
|
||||
Connected: 'connected',
|
||||
} as const;
|
||||
|
||||
export type TelegramConnectStatusValue =
|
||||
(typeof TelegramConnectStatus)[keyof typeof TelegramConnectStatus];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue