'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 connect a second telegram channel when one is already connected', function () { config(['trypost.self_hosted' => false]); Event::fake([TelegramConnectFailed::class]); SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => '-1009999999999', ]); $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(1); expect( SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists() )->toBeFalse(); Event::assertDispatched( TelegramConnectFailed::class, fn (TelegramConnectFailed $event): bool => $event->workspaceId === $this->workspace->id && $event->reason === 'network_taken', ); }); it('connects a second telegram channel in self-hosted mode', function () { Http::fake(); config(['trypost.self_hosted' => true]); SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, 'platform_user_id' => '-1009999999999', ]); $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(2); expect( SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists() )->toBeTrue(); }); it('reconnects an existing telegram channel', function () { Http::fake(); config(['trypost.self_hosted' => false]); 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(1); 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'); }); });