From 9e0917bb926595f33fc105c4ea3a24edbf22b13d Mon Sep 17 00:00:00 2001 From: Rohit Date: Fri, 30 Oct 2026 11:14:22 +0530 Subject: [PATCH] feat: let the mobile app make and answer calls The signed /api/v1 gets the web's one-to-one call signalling, so an app can ring someone, answer, hang up and fetch relay credentials, and calls work between the app and the web either way round. The endpoints mirror the web routes and reuse their actions, query and TURN provider, with the same rules, validation and throttles. The incoming call sends its caller as a UserResource. Org calls stay web-only for now. --- app/Http/Controllers/Api/CallController.php | 31 ++++ .../Api/ConversationCallController.php | 59 +++++++ .../ConversationCallIceServerController.php | 25 +++ .../CreateApiConversationCallRequest.php | 62 +++++++ .../DeleteApiConversationCallRequest.php | 42 +++++ ...stApiConversationCallIceServersRequest.php | 40 +++++ .../ShowApiConversationCallRequest.php | 31 ++++ .../UpdateApiConversationCallRequest.php | 58 +++++++ routes/api.php | 21 +++ .../Controllers/Api/CallControllerTest.php | 38 +++++ .../Api/ConversationCallControllerTest.php | 153 ++++++++++++++++++ ...onversationCallIceServerControllerTest.php | 44 +++++ 12 files changed, 604 insertions(+) create mode 100644 app/Http/Controllers/Api/CallController.php create mode 100644 app/Http/Controllers/Api/ConversationCallController.php create mode 100644 app/Http/Controllers/Api/ConversationCallIceServerController.php create mode 100644 app/Http/Requests/CreateApiConversationCallRequest.php create mode 100644 app/Http/Requests/DeleteApiConversationCallRequest.php create mode 100644 app/Http/Requests/ListApiConversationCallIceServersRequest.php create mode 100644 app/Http/Requests/ShowApiConversationCallRequest.php create mode 100644 app/Http/Requests/UpdateApiConversationCallRequest.php create mode 100644 tests/Feature/Controllers/Api/CallControllerTest.php create mode 100644 tests/Feature/Controllers/Api/ConversationCallControllerTest.php create mode 100644 tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php diff --git a/app/Http/Controllers/Api/CallController.php b/app/Http/Controllers/Api/CallController.php new file mode 100644 index 000000000..ad231fbe3 --- /dev/null +++ b/app/Http/Controllers/Api/CallController.php @@ -0,0 +1,31 @@ +incoming($user); + + return response()->json([ + 'call' => $call === null ? null : [ + 'partner' => UserResource::make($call['partner']), + 'sdp' => $call['sdp'], + 'is_muted' => $call['is_muted'], + ], + ])->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Controllers/Api/ConversationCallController.php b/app/Http/Controllers/Api/ConversationCallController.php new file mode 100644 index 000000000..62288f054 --- /dev/null +++ b/app/Http/Controllers/Api/ConversationCallController.php @@ -0,0 +1,59 @@ +json(['call' => $calls->outgoing($user, $partner)])->header('Cache-Control', 'private, no-store'); + } + + /** + * Rings the partner with the app's offer, which works the same whether they answer on the web or in the app. + */ + public function store(CreateApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, CreateConversationCall $action): Response + { + $action->handle($user, $partner, $request->string('sdp')->value()); + + return response()->noContent(); + } + + /** + * Answers the partner's call with the app's answer. + */ + public function update(UpdateApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, UpdateConversationCall $action): Response + { + $action->handle($user, $partner, $request->call(), $request->string('sdp')->value()); + + return response()->noContent(); + } + + /** + * Ends the call between the two, whichever of them rang, as the web does. + */ + public function destroy(DeleteApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, DeleteConversationCall $action): Response + { + $action->handle($user, $partner, $request->unanswered()); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/ConversationCallIceServerController.php b/app/Http/Controllers/Api/ConversationCallIceServerController.php new file mode 100644 index 000000000..93ec07f05 --- /dev/null +++ b/app/Http/Controllers/Api/ConversationCallIceServerController.php @@ -0,0 +1,25 @@ +json([ + 'ice_servers' => $turn->iceServers(), + 'is_relay_only' => $turn->isRelayOnly(), + ])->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Requests/CreateApiConversationCallRequest.php b/app/Http/Requests/CreateApiConversationCallRequest.php new file mode 100644 index 000000000..4cecc1266 --- /dev/null +++ b/app/Http/Requests/CreateApiConversationCallRequest.php @@ -0,0 +1,62 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + if (! $user->canMessage($partner)) { + return Response::denyAsNotFound(); + } + + if (! $turn->isAvailable()) { + return Response::deny("Calls aren't available right now."); + } + + if ($orgCalls->isInOne($user)) { + return Response::deny("You're already on a call."); + } + + if ($access->get($user, $partner) !== PushAccess::Open || $calls->get($partner)?->isFrom($user) === false || $orgCalls->isInOne($partner)) { + return Response::deny("You can't call {$partner->mention()} right now."); + } + + return Conversation::between($user, $partner) instanceof Conversation + ? Response::allow() + : Response::deny("Say hi to {$partner->mention()} before calling them."); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'sdp' => ['required', 'string', 'starts_with:v=0', 'max:10000'], + ]; + } +} diff --git a/app/Http/Requests/DeleteApiConversationCallRequest.php b/app/Http/Requests/DeleteApiConversationCallRequest.php new file mode 100644 index 000000000..2055627a6 --- /dev/null +++ b/app/Http/Requests/DeleteApiConversationCallRequest.php @@ -0,0 +1,42 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + return $user->canMessage($partner) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'failed' => ['sometimes', 'boolean'], + ]; + } + + /** + * What the user ending a call they didn't pick up did: declined it, or tried to answer and couldn't. + */ + public function unanswered(): OrgEvent + { + return $this->boolean('failed') ? OrgEvent::CallFailed : OrgEvent::CallDeclined; + } +} diff --git a/app/Http/Requests/ListApiConversationCallIceServersRequest.php b/app/Http/Requests/ListApiConversationCallIceServersRequest.php new file mode 100644 index 000000000..301969f4a --- /dev/null +++ b/app/Http/Requests/ListApiConversationCallIceServersRequest.php @@ -0,0 +1,40 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + if (! $user->canMessage($partner) || ! Conversation::between($user, $partner) instanceof Conversation) { + return Response::denyAsNotFound(); + } + + return $turn->isAvailable() ? Response::allow() : Response::deny("Calls aren't available right now."); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ShowApiConversationCallRequest.php b/app/Http/Requests/ShowApiConversationCallRequest.php new file mode 100644 index 000000000..611e1714f --- /dev/null +++ b/app/Http/Requests/ShowApiConversationCallRequest.php @@ -0,0 +1,31 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + return $user->canMessage($partner) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/UpdateApiConversationCallRequest.php b/app/Http/Requests/UpdateApiConversationCallRequest.php new file mode 100644 index 000000000..0958ed250 --- /dev/null +++ b/app/Http/Requests/UpdateApiConversationCallRequest.php @@ -0,0 +1,58 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + if (! $user->canMessage($partner)) { + return Response::denyAsNotFound(); + } + + return ($calls->incoming($user)['partner']->id ?? null) === $partner->id + ? Response::allow() + : Response::deny('This call has ended.'); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'sdp' => ['required', 'string', 'starts_with:v=0', 'max:10000'], + ]; + } + + /** + * The call being answered. + */ + public function call(): Call + { + $user = $this->user(); + assert($user instanceof User); + + $call = resolve(CallQuery::class)->get($user); + assert($call !== null); + + return $call; + } +} diff --git a/routes/api.php b/routes/api.php index 67cc0a186..abe0aebae 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Exceptions\ApiException; +use App\Http\Controllers\Api\CallController; use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; use App\Http\Controllers\Api\CommitPinController; @@ -15,6 +16,8 @@ use App\Http\Controllers\Api\CommitStarController; use App\Http\Controllers\Api\CommitTranslationController; use App\Http\Controllers\Api\ConversationArchiveController; +use App\Http\Controllers\Api\ConversationCallController; +use App\Http\Controllers\Api\ConversationCallIceServerController; use App\Http\Controllers\Api\ConversationController; use App\Http\Controllers\Api\ConversationDropController; use App\Http\Controllers\Api\ConversationMergeController; @@ -355,6 +358,24 @@ ->middleware([PreventPostingInTimeout::class, 'throttle:pings']) ->name('api-org-ping.store'); + // Remote Calls: the app swaps WebRTC offers and answers through these as the web's calls do, so either side may + // be the web or the app... + Route::middleware('throttle:conversations')->group(function (): void { + Route::get('remotes/call', [CallController::class, 'show'])->name('api-call.show'); + Route::get('remotes/@{partner:handle}/call', [ConversationCallController::class, 'show']) + ->name('api-conversation-call.show'); + Route::put('remotes/@{partner:handle}/call', [ConversationCallController::class, 'update']) + ->name('api-conversation-call.update'); + Route::delete('remotes/@{partner:handle}/call', [ConversationCallController::class, 'destroy']) + ->name('api-conversation-call.destroy'); + }); + Route::get('remotes/@{partner:handle}/call/ice-servers', [ConversationCallIceServerController::class, 'index']) + ->middleware('throttle:messages') + ->name('api-conversation-call-ice-server.index'); + Route::post('remotes/@{partner:handle}/call', [ConversationCallController::class, 'store']) + ->middleware([PreventPostingInTimeout::class, 'throttle:messages']) + ->name('api-conversation-call.store'); + // Remote Reactions... Route::middleware('throttle:conversations')->group(function (): void { Route::get('remotes/reactions', [MessageReactionController::class, 'index'])->name('api-message-reaction.index'); diff --git a/tests/Feature/Controllers/Api/CallControllerTest.php b/tests/Feature/Controllers/Api/CallControllerTest.php new file mode 100644 index 000000000..d8a687e31 --- /dev/null +++ b/tests/Feature/Controllers/Api/CallControllerTest.php @@ -0,0 +1,38 @@ + true, 'remotes.everyone' => true]); + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->partner = User::factory()->withRemotes()->create(); + sendChatMessage($this->partner, $this->user); +}); + +it('shows the call ringing for the user, with its caller and offer', function (): void { + resolve(CreateConversationCall::class)->handle($this->partner, $this->user, "v=0\r\ns=offer\r\n"); + + signedApiRequest($this->keyPair, 'GET', route('api-call.show'))->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('call.partner.handle', $this->partner->handle) + ->assertJsonPath('call.sdp', "v=0\r\ns=offer\r\n") + ->assertJsonPath('call.is_muted', false); +}); + +it('stops ringing once the user answers', function (): void { + resolve(CreateConversationCall::class)->handle($this->partner, $this->user, "v=0\r\n"); + resolve(UpdateConversationCall::class)->handle($this->user, $this->partner, resolve(CallQuery::class)->get($this->user), "v=0\r\n"); + + signedApiRequest($this->keyPair, 'GET', route('api-call.show'))->assertOk() + ->assertExactJson(['call' => null]); +}); + +it('returns 401 for an unsigned check', function (): void { + $this->getJson(route('api-call.show'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/ConversationCallControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php new file mode 100644 index 000000000..7602919d9 --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php @@ -0,0 +1,153 @@ + true, + 'remotes.everyone' => true, + 'services.cloudflare_turn.key_id' => 'key', + 'services.cloudflare_turn.api_token' => 'token', + ]); + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->partner = User::factory()->withRemotes()->create(); + $this->offer = "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=offer\r\n"; + $this->answer = "v=0\r\no=- 3 4 IN IP4 127.0.0.1\r\ns=answer\r\n"; +}); + +it('rings the partner with the offer, which the web picks up and answers', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertNoContent(); + + expect(resolve(CallQuery::class)->incoming($this->partner))->toMatchArray(['sdp' => $this->offer]) + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe(OrgEvent::CallStarted); + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call.show', $this->partner))->assertOk() + ->assertExactJson(['call' => ['answer' => null]]); + + $this->actingAs($this->partner)->putJson(route('conversation-call.update', $this->user), ['sdp' => $this->answer])->assertNoContent(); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call.show', $this->partner))->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['call' => ['answer' => $this->answer]]); +}); + +it('has no outgoing call before ringing', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call.show', $this->partner))->assertOk() + ->assertExactJson(['call' => null]); +}); + +it('answers a call the partner placed on the web', function (): void { + sendChatMessage($this->partner, $this->user); + $this->actingAs($this->partner)->postJson(route('conversation-call.store', $this->user), ['sdp' => $this->offer])->assertNoContent(); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-call.update', $this->partner), ['sdp' => $this->answer])->assertNoContent(); + + expect(resolve(CallQuery::class)->outgoing($this->partner, $this->user))->toBe(['answer' => $this->answer]) + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe(OrgEvent::CallAnswered); +}); + +it('does not answer a call that is not ringing', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-call.update', $this->partner), ['sdp' => $this->answer])->assertForbidden() + ->assertJsonPath('message', 'This call has ended.'); +}); + +it('logs how an unanswered call ended', function (bool $isCaller, array $data, OrgEvent $event): void { + sendChatMessage($this->partner, $this->user); + [$caller, $callee] = $isCaller ? [$this->user, $this->partner] : [$this->partner, $this->user]; + resolve(CreateConversationCall::class)->handle($caller, $callee, $this->offer); + + signedApiRequest($this->keyPair, 'DELETE', route('api-conversation-call.destroy', $this->partner), $data)->assertNoContent(); + + expect(resolve(CallQuery::class)->get($callee))->toBeNull() + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe($event); +})->with([ + 'cancelled' => [true, [], OrgEvent::CallCancelled], + 'declined' => [false, [], OrgEvent::CallDeclined], + 'failed to answer' => [false, ['failed' => true], OrgEvent::CallFailed], +]); + +it('requires a conversation before calling', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "Say hi to {$this->partner->mention()} before calling them."); + + expect(resolve(CallQuery::class)->get($this->partner))->toBeNull(); +}); + +it('does not call while calls are not available', function (): void { + sendChatMessage($this->partner, $this->user); + config(['services.cloudflare_turn.key_id' => null]); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "Calls aren't available right now."); +}); + +it('does not call while the user is on an org call', function (): void { + sendChatMessage($this->partner, $this->user); + cache()->put(CreateOrgCall::memberKey($this->user->id), fake()->uuid(), 60); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "You're already on a call."); +}); + +it('does not call a partner who cannot take it', function (Closure $busy): void { + sendChatMessage($this->partner, $this->user); + $busy($this->partner); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "You can't call {$this->partner->mention()} right now."); +})->with([ + 'with Remotes disabled' => [fn (User $partner): bool => $partner->update(['remotes_enabled' => false])], + 'ringing for someone else' => [function (User $partner): void { + $caller = User::factory()->withRemotes()->create(); + sendChatMessage($caller, $partner); + resolve(CreateConversationCall::class)->handle($caller, $partner, "v=0\r\n"); + }], + 'on an org call' => [fn (User $partner): bool => cache()->put(CreateOrgCall::memberKey($partner->id), fake()->uuid(), 60)], +]); + +it('rejects an offer that is not a session description', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => 'hello'])->assertUnprocessable() + ->assertJsonValidationErrors('sdp'); +}); + +it('hides calls with someone either person blocks', function (string $method, string $route, array $data): void { + sendChatMessage($this->partner, $this->user); + $this->partner->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, $method, route($route, $this->partner), $data)->assertNotFound(); +})->with([ + 'show' => ['GET', 'api-conversation-call.show', []], + 'store' => ['POST', 'api-conversation-call.store', ['sdp' => "v=0\r\n"]], + 'update' => ['PUT', 'api-conversation-call.update', ['sdp' => "v=0\r\n"]], + 'destroy' => ['DELETE', 'api-conversation-call.destroy', []], +]); + +it('hides calls while the user has Remotes disabled', function (): void { + sendChatMessage($this->partner, $this->user); + $this->user->update(['remotes_enabled' => false]); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertNotFound(); + + expect(resolve(CallQuery::class)->get($this->partner))->toBeNull(); +}); + +it('returns 401 for an unsigned call', function (): void { + $this->postJson(route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertUnauthorized(); + + expect(resolve(CallQuery::class)->get($this->partner))->toBeNull(); +}); diff --git a/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php new file mode 100644 index 000000000..c8a0e3f90 --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php @@ -0,0 +1,44 @@ + true, + 'remotes.everyone' => true, + 'services.cloudflare_turn.key_id' => 'key', + 'services.cloudflare_turn.api_token' => 'token', + ]); + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->partner = User::factory()->withRemotes()->create(); +}); + +it('hands out fresh relay credentials for a call with the partner', function (): void { + sendChatMessage($this->partner, $this->user); + Http::fake(['rtc.live.cloudflare.com/*' => Http::response(['iceServers' => [ + ['urls' => ['turn:turn.cloudflare.com:3478?transport=udp'], 'username' => 'user', 'credential' => 'secret'], + ]])]); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call-ice-server.index', $this->partner))->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson([ + 'ice_servers' => [['urls' => ['turn:turn.cloudflare.com:3478?transport=udp'], 'username' => 'user', 'credential' => 'secret']], + 'is_relay_only' => true, + ]); +}); + +it('hands out nothing without a conversation', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call-ice-server.index', $this->partner))->assertNotFound(); +}); + +it('hands out nothing while calls are not available', function (): void { + sendChatMessage($this->partner, $this->user); + config(['services.cloudflare_turn.api_token' => null]); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call-ice-server.index', $this->partner))->assertForbidden() + ->assertJsonPath('message', "Calls aren't available right now."); +});