Repository: nextcloud/spreed
Pull request #14731: feat(sip): Allow the sipbridge to create a conversation with a user for  a phone number
Author: login: nickvergessen
Labels: name: enhancement
name: feature: api 🛠️
description: OCS API for conversations, chats and participants
name: feature: SFU & SIP ☎️
Created: 2025-03-27T21:36:53Z | Merged: 2025-04-30T13:25:34Z

### ☑️ Resolves

- [x] Based on #14729

## 🛠️ API Checklist
### 🏁 Checklist

- [x] ⛑️ Tests (unit and/or integration) are included or not possible
- [x] 📘 API documentation in `docs/` has been updated or is not required
- [x] 🔖 Capability is added or not needed 



Diff:
diff --git a/appinfo/routes/routesRoomController.php b/appinfo/routes/routesRoomController.php
index c46593db3d8..535f11d761e 100644
--- a/appinfo/routes/routesRoomController.php
+++ b/appinfo/routes/routesRoomController.php
@@ -95,6 +95,8 @@
 		['name' => 'Room#verifyDialInPin', 'url' => '/api/{apiVersion}/room/{token}/verify-dialin', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
 		/** @see \OCA\Talk\Controller\RoomController::verifyDialOutNumber() */
 		['name' => 'Room#verifyDialOutNumber', 'url' => '/api/{apiVersion}/room/{token}/verify-dialout', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
+		/** @see \OCA\Talk\Controller\RoomController::directDialIn() */
+		['name' => 'Room#directDialIn', 'url' => '/api/{apiVersion}/room/direct-dial-in', 'verb' => 'POST', 'requirements' => $requirements],
 		/** @see \OCA\Talk\Controller\RoomController::createGuestByDialIn() */
 		['name' => 'Room#createGuestByDialIn', 'url' => '/api/{apiVersion}/room/{token}/open-dial-in', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
 		/** @see \OCA\Talk\Controller\RoomController::rejectedDialOutRequest() */
diff --git a/docs/capabilities.md b/docs/capabilities.md
index a9a3a09adc1..b2e43bc2470 100644
--- a/docs/capabilities.md
+++ b/docs/capabilities.md
@@ -170,7 +170,7 @@
 ## 21
 * `config => conversations => force-passwords` - Whether passwords are enforced for public rooms
 * `conversation-creation-password` - Whether the endpoints for creating public conversations or making a conversation public support setting a password
-* `call-notification-state-api` (local) - Whether the endpoints exists for checking if a call notification should be dismissed
+* `call-notification-state-api` (local) - Whether the endpoints exist for checking if a call notification should be dismissed
 * `schedule-meeting` (local) - Whether logged-in participants can schedule meetings
 * `config => chat => has-translation-task-providers` (local) - When true, translations can be done using the [OCS TaskProcessing API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html).
 * `config => conversations => list-style` (local) - Whether conversation list should appear in certain way
@@ -180,6 +180,7 @@
 + `edit-draft-poll` - Whether moderators can edit draft polls
 
 ## 21.1
-* `conversation-creation-all` - Whether the conversation creation endpoint allows to specify all attributes of a conversation
+* `conversation-creation-all` (local) - Whether the conversation creation endpoint allows to specify all attributes of a conversation
+* `sip-direct-dialin` (local) - Whether the SIP bridge can create conversations when an external participant calls a mapped phone number
 * `important-conversations` (local) - Whether important conversations are supported
 * `config => call => predefined-backgrounds-v2` (local) - Whether virtual backgrounds should be read from the theming directory
diff --git a/lib/Capabilities.php b/lib/Capabilities.php
index ddf279d3b8d..b7b0b79c2fc 100644
--- a/lib/Capabilities.php
+++ b/lib/Capabilities.php
@@ -116,6 +116,7 @@ class Capabilities implements IPublicCapability {
 		'edit-draft-poll',
 		'conversation-creation-all',
 		'important-conversations',
+		'sip-direct-dialin',
 	];
 
 	public const CONDITIONAL_FEATURES = [
@@ -139,7 +140,9 @@ class Capabilities implements IPublicCapability {
 		'chat-summary-api',
 		'call-notification-state-api',
 		'schedule-meeting',
+		'conversation-creation-all',
 		'important-conversations',
+		'sip-direct-dialin',
 	];
 
 	public const LOCAL_CONFIGS = [
diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php
index 77a13fd7d14..77ea2aadf66 100644
--- a/lib/Controller/RoomController.php
+++ b/lib/Controller/RoomController.php
@@ -61,6 +61,7 @@
 use OCA\Talk\Service\InvitationService;
 use OCA\Talk\Service\NoteToSelfService;
 use OCA\Talk\Service\ParticipantService;
+use OCA\Talk\Service\PhoneService;
 use OCA\Talk\Service\RecordingService;
 use OCA\Talk\Service\RoomFormatter;
 use OCA\Talk\Service\RoomService;
@@ -69,6 +70,7 @@
 use OCA\Talk\TalkSession;
 use OCA\Talk\Webinary;
 use OCP\App\IAppManager;
+use OCP\AppFramework\Db\DoesNotExistException;
 use OCP\AppFramework\Http;
 use OCP\AppFramework\Http\Attribute\BruteForceProtection;
 use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -136,6 +138,7 @@ public function __construct(
 		protected Config $talkConfig,
 		protected ICloudIdManager $cloudIdManager,
 		protected IPhoneNumberUtil $phoneNumberUtil,
+		protected PhoneService $phoneService,
 		protected IThrottler $throttler,
 		protected LoggerInterface $logger,
 		protected Authenticator $federationAuthenticator,
@@ -681,6 +684,10 @@ public function createRoom(
 			$roomType = Room::TYPE_PUBLIC;
 		}
 
+		if ($objectType === Room::OBJECT_TYPE_PHONE) {
+			$objectId = Room::OBJECT_ID_PHONE_OUTGOING;
+		}
+
 		try {
 			$room = $this->roomService->createConversation(
 				$roomType,
@@ -1656,7 +1663,7 @@ public function setPassword(string $password): DataResponse {
 	/**
 	 * Archive a conversation
 	 *
-	 *  Required capability: `archived-conversations-v2`
+	 * Required capability: `archived-conversations-v2`
 	 *
 	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
 	 *
@@ -1938,6 +1945,10 @@ public function joinFederatedRoom(string $token, ?string $sessionId): DataRespon
 	#[OpenAPI(scope: 'backend-sipbridge')]
 	#[RequireRoom]
 	public function verifyDialInPin(string $pin): DataResponse {
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
 		try {
 			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
 				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
@@ -1950,17 +1961,75 @@ public function verifyDialInPin(string $pin): DataResponse {
 			return $response;
 		}
 
+		try {
+			$participant = $this->participantService->getParticipantByPin($this->room, $pin);
+		} catch (ParticipantNotFoundException $e) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
+	}
+
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * Required capability: `sip-direct-dialin`
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Call conversation created
+	 * 401: SIP request invalid
+	 * 404: Number is not assigned to any user
+	 * 500: Error occurred while creating conversation
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
 		if (!$this->talkConfig->isSIPConfigured()) {
 			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
 		}
 
 		try {
-			$participant = $this->participantService->getParticipantByPin($this->room, $pin);
-		} catch (ParticipantNotFoundException $e) {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
 			return new DataResponse(null, Http::STATUS_NOT_FOUND);
 		}
 
-		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
+		$caller = trim($caller);
+		// TODO: Use later and get name from addressbook? $cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		$user = $this->userManager->get($entity->getActorId());
+		try {
+			$room = $this->roomService->createConversation(
+				Room::TYPE_GROUP,
+				$caller,
+				$user,
+				Room::OBJECT_TYPE_PHONE,
+				Room::OBJECT_ID_PHONE_INCOMING,
+				sipEnabled: Webinary::SIP_ENABLED_NO_PIN,
+			);
+		} catch (CreationException|PasswordException $e) {
+			$this->logger->error('Error occurred while creating SIP direct dial-in conversation', ['exception' => $e]);
+			return new DataResponse(null, Http::STATUS_INTERNAL_SERVER_ERROR);
+		}
+
+		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, '', true, displayName: $caller);
+		return new DataResponse($this->formatRoom($room, $participant));
 	}
 
 	/**
@@ -1981,6 +2050,10 @@ public function verifyDialInPin(string $pin): DataResponse {
 	#[OpenAPI(scope: 'backend-sipbridge')]
 	#[RequireRoom]
 	public function verifyDialOutNumber(string $number, array $options = []): DataResponse {
+		if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->isSIPDialOutEnabled()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
 		try {
 			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
 				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
@@ -1993,10 +2066,6 @@ public function verifyDialOutNumber(string $number, array $options = []): DataRe
 			return $response;
 		}
 
-		if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->isSIPDialOutEnabled()) {
-			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
-		}
-
 		if (!isset($options['actorId'], $options['actorType']) || $options['actorType'] !== Attendee::ACTOR_PHONES) {
 			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
 		}
@@ -2067,6 +2136,10 @@ public function createGuestByDialIn(): DataResponse {
 	#[OpenAPI(scope: 'backend-sipbridge')]
 	#[RequireRoom]
 	public function rejectedDialOutRequest(string $callId, array $options = []): DataResponse {
+		if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->isSIPDialOutEnabled()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
 		try {
 			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
 				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
@@ -2083,10 +2156,6 @@ public function rejectedDialOutRequest(string $callId, array $options = []): Dat
 			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
 		}
 
-		if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->isSIPDialOutEnabled()) {
-			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
-		}
-
 		try {
 			$this->participantService->resetDialOutRequest($this->room, $options['attendeeId'], $callId);
 		} catch (ParticipantNotFoundException) {
diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php
index 48d56e5a852..9e2c14b8e7e 100644
--- a/lib/Controller/SignalingController.php
+++ b/lib/Controller/SignalingController.php
@@ -22,6 +22,7 @@
 use OCA\Talk\Room;
 use OCA\Talk\Service\BanService;
 use OCA\Talk\Service\ParticipantService;
+use OCA\Talk\Service\RoomService;
 use OCA\Talk\Service\SessionService;
 use OCA\Talk\Signaling\Messages;
 use OCA\Talk\TalkSession;
@@ -58,6 +59,7 @@ public function __construct(
 		private TalkSession $session,
 		private Manager $manager,
 		private ParticipantService $participantService,
+		private RoomService $roomService,
 		private SessionService $sessionService,
 		private IDBConnection $dbConnection,
 		private Messages $messages,
@@ -858,7 +860,9 @@ private function backendRoom(array $roomRequest): DataResponse {
 
 			if ($participant->getSession() instanceof Session) {
 				if ($inCall !== null) {
-					$this->participantService->changeInCall($room, $participant, $inCall);
+					$lastJoinedCall = $this->timeFactory->getDateTime();
+					$this->participantService->changeInCall($room, $participant, $inCall, lastJoinedCall: $lastJoinedCall->getTimestamp());
+					$this->roomService->setActiveSince($room, $participant, $lastJoinedCall, callFlag: $inCall, silent: false);
 				}
 				$this->sessionService->updateLastPing($participant->getSession(), $this->timeFactory->getTime());
 			}
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index 9ad4ea3f589..eccc279a991 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -1051,6 +1051,31 @@ protected function parseCall(INotification $notification, Room $room, IL10N $l):
 			} else {
 				throw new AlreadyProcessedException();
 			}
+		} elseif ($room->getObjectType() === Room::OBJECT_TYPE_PHONE && $room->getObjectId() === Room::OBJECT_ID_PHONE_INCOMING) {
+			if ($this->notificationManager->isPreparingPushNotification()
+				|| (!$room->isFederatedConversation() && $this->participantService->hasActiveSessionsInCall($room))
+				|| ($room->isFederatedConversation() && $room->getActiveSince())
+			) {
+				$notification = $this->addActionButton($notification, 'call_view', $l->t('Accept call'), true, true);
+				$subject = $l->t('Incoming phone call from {call}');
+			} else {
+				$notification = $this->addActionButton($notification, 'chat_view', $l->t('View chat'), false);
+				$subject = $l->t('You missed a phone call from {call}');
+			}
+
+			$notification
+				->setParsedSubject(str_replace('{call}', $roomName, $subject))
+				->setRichSubject(
+					$subject, [
+						'call' => [
+							'type' => 'call',
+							'id' => (string)$room->getId(),
+							'name' => $roomName,
+							'call-type' => $this->getRoomType($room),
+							'icon-url' => $this->avatarService->getAvatarUrl($room),
+						],
+					]
+				);
 		} elseif (\in_array($room->getType(), [Room::TYPE_GROUP, Room::TYPE_PUBLIC], true)) {
 			if ($this->notificationManager->isPreparingPushNotification()
 				|| (!$room->isFederatedConversation() && $this->participantService->hasActiveSessionsInCall($room))
diff --git a/lib/Room.php b/lib/Room.php
index e43421ec371..5587369e367 100644
--- a/lib/Room.php
+++ b/lib/Room.php
@@ -48,6 +48,9 @@ class Room {
 	public const OBJECT_TYPE_SAMPLE = 'sample';
 	public const OBJECT_TYPE_VIDEO_VERIFICATION = 'share:password';
 
+	public const OBJECT_ID_PHONE_OUTGOING = 'phone';
+	public const OBJECT_ID_PHONE_INCOMING = 'direct-dialin';
+
 	public const RECORDING_NONE = 0;
 	public const RECORDING_VIDEO = 1;
 	public const RECORDING_AUDIO = 2;
diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php
index d0c4fea926b..3905dae1401 100644
--- a/lib/Service/ParticipantService.php
+++ b/lib/Service/ParticipantService.php
@@ -445,7 +445,7 @@ public function joinRoomAsFederatedUser(Room $room, string $actorType, string $a
 	 * @throws InvalidPasswordException
 	 * @throws UnauthorizedException
 	 */
-	public function joinRoomAsNewGuest(RoomService $roomService, Room $room, string $password, bool $passedPasswordProtection = false, ?Participant $previousParticipant = null): Participant {
+	public function joinRoomAsNewGuest(RoomService $roomService, Room $room, string $password, bool $passedPasswordProtection = false, ?Participant $previousParticipant = null, ?string $displayName = null): Participant {
 		$event = new BeforeGuestJoinedRoomEvent($room, $password, $passedPasswordProtection);
 		$this->dispatcher->dispatchTyped($event);
 
@@ -474,6 +474,11 @@ public function joinRoomAsNewGuest(RoomService $roomService, Room $room, string
 			$attendee->setParticipantType(Participant::GUEST);
 			$attendee->setPermissions(Attendee::PERMISSIONS_DEFAULT);
 			$attendee->setLastReadMessage($lastMessage);
+
+			if ($displayName !== null && $displayName !== '') {
+				$attendee->setDisplayName($displayName);
+			}
+
 			$this->attendeeMapper->insert($attendee);
 
 			$attendeeEvent = new AttendeesAddedEvent($room, [$attendee]);
diff --git a/lib/Service/PhoneService.php b/lib/Service/PhoneService.php
new file mode 100644
index 00000000000..416278ed370
--- /dev/null
+++ b/lib/Service/PhoneService.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Talk\Service;
+
+use OCA\Talk\Model\PhoneNumber;
+use OCA\Talk\Model\PhoneNumberMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\User\IAvailabilityCoordinator;
+
+class PhoneService {
+
+	public function __construct(
+		protected IUserManager $userManager,
+		protected IAvailabilityCoordinator $availabilityCoordinator,
+		protected PhoneNumberMapper $mapper,
+	) {
+	}
+
+	/**
+	 * Get user ID to call
+	 *
+	 * Internally falling back to the OOO replacement,
+	 * if one is defined that has a phone number and is not OOO itself.
+	 *
+	 * @throws DoesNotExistException
+	 */
+	public function getAccountToCallForPhoneNumber(string $phoneNumber): PhoneNumber {
+		$entity = $this->mapper->findByPhoneNumber($phoneNumber);
+
+		$user = $this->userManager->get($entity->getActorId());
+		if (!$user instanceof IUser) {
+			throw new DoesNotExistException('Invalid user');
+		}
+
+		$outOfOffice = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user);
+		if ($outOfOffice === null
+			|| $outOfOffice->getReplacementUserId() === null
+			|| !$this->availabilityCoordinator->isInEffect($outOfOffice)) {
+			return $entity;
+		}
+
+		$replacementUser = $this->userManager->get($outOfOffice->getReplacementUserId());
+		if (!$replacementUser instanceof IUser) {
+			// Replacement is wrong, fall back to original user
+			return $entity;
+		}
+
+		$outOfOffice = $this->availabilityCoordinator->getCurrentOutOfOfficeData($replacementUser);
+		if ($outOfOffice !== null
+			&& $this->availabilityCoordinator->isInEffect($outOfOffice)) {
+			// Replacement is also OOO, fall back to original user
+			return $entity;
+		}
+
+		$entities = $this->mapper->findByUser($replacementUser->getUID());
+		if (empty($entities)) {
+			// Replacement has no phone number, fall back to original user
+			return $entity;
+		}
+
+		return array_shift($entities);
+	}
+
+	/**
+	 * @return list<PhoneNumber>
+	 */
+	public function findByUser(string $userId): array {
+		return $this->mapper->findByUser($userId);
+	}
+
+	/**
+	 * @return list<PhoneNumber>
+	 */
+	public function findByPhoneNumbers(array $phoneNumbers): array {
+		return $this->mapper->findByPhoneNumbers($phoneNumbers);
+	}
+
+	public function deleteByPhoneNumber(string $phoneNumber): void {
+		$this->mapper->deleteByPhoneNumber($phoneNumber);
+	}
+
+	public function deleteByUser(string $userId): void {
+		$this->mapper->deleteByUser($userId);
+	}
+}
diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json
index 4e1de04da56..963b4ad1bd0 100644
--- a/openapi-backend-sipbridge.json
+++ b/openapi-backend-sipbridge.json
@@ -1608,6 +1608,225 @@
                 }
             }
         },
+        "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/direct-dial-in": {
+            "post": {
+                "operationId": "room-direct-dial-in",
+                "summary": "Direct dial-in (SIP bridge)",
+                "description": "Required capability: `sip-direct-dialin`",
+                "tags": [
+                    "room"
+                ],
+                "security": [
+                    {},
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "requestBody": {
+                    "required": true,
+                    "content": {
+                        "application/json": {
+                            "schema": {
+                                "type": "object",
+                                "required": [
+                                    "phoneNumber",
+                                    "caller"
+                                ],
+                                "properties": {
+                                    "phoneNumber": {
+                                        "type": "string",
+                                        "description": "Phone number that is called"
+                                    },
+                                    "caller": {
+                                        "type": "string",
+                                        "description": "Phone number of the person calling in"
+                                    }
+                                }
+                            }
+                        }
+                    }
+                },
+                "parameters": [
+                    {
+                        "name": "apiVersion",
+                        "in": "path",
+                        "required": true,
+                        "schema": {
+                            "type": "string",
+                            "enum": [
+                                "v4"
+                            ],
+                            "default": "v4"
+                        }
+                    },
+                    {
+                        "name": "OCS-APIRequest",
+                        "in": "header",
+                        "description": "Required to be true for the API request to pass",
+                        "required": true,
+                        "schema": {
+                            "type": "boolean",
+                            "default": true
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Call conversation created",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "$ref": "#/components/schemas/Room"
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "SIP request invalid",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Number is not assigned to any user",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Error occurred while creating conversation",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "501": {
+                        "description": "SIP dial-in is not configured",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
         "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/open-dial-in": {
             "post": {
                 "operationId": "room-create-guest-by-dial-in",
diff --git a/openapi-full.json b/openapi-full.json
index e0be617c7f1..3f220a67a37 100644
--- a/openapi-full.json
+++ b/openapi-full.json
@@ -23471,6 +23471,225 @@
                 }
             }
         },
+        "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/direct-dial-in": {
+            "post": {
+                "operationId": "room-direct-dial-in",
+                "summary": "Direct dial-in (SIP bridge)",
+                "description": "Required capability: `sip-direct-dialin`",
+                "tags": [
+                    "room"
+                ],
+                "security": [
+                    {},
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "requestBody": {
+                    "required": true,
+                    "content": {
+                        "application/json": {
+                            "schema": {
+                                "type": "object",
+                                "required": [
+                                    "phoneNumber",
+                                    "caller"
+                                ],
+                                "properties": {
+                                    "phoneNumber": {
+                                        "type": "string",
+                                        "description": "Phone number that is called"
+                                    },
+                                    "caller": {
+                                        "type": "string",
+                                        "description": "Phone number of the person calling in"
+                                    }
+                                }
+                            }
+                        }
+                    }
+                },
+                "parameters": [
+                    {
+                        "name": "apiVersion",
+                        "in": "path",
+                        "required": true,
+                        "schema": {
+                            "type": "string",
+                            "enum": [
+                                "v4"
+                            ],
+                            "default": "v4"
+                        }
+                    },
+                    {
+                        "name": "OCS-APIRequest",
+                        "in": "header",
+                        "description": "Required to be true for the API request to pass",
+                        "required": true,
+                        "schema": {
+                            "type": "boolean",
+                            "default": true
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Call conversation created",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "$ref": "#/components/schemas/Room"
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "SIP request invalid",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Number is not assigned to any user",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Error occurred while creating conversation",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "501": {
+                        "description": "SIP dial-in is not configured",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
         "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/open-dial-in": {
             "post": {
                 "operationId": "room-create-guest-by-dial-in",
diff --git a/src/components/TopBar/CallButton.vue b/src/components/TopBar/CallButton.vue
index 48a8ccf46ba..c978f8d639b 100644
--- a/src/components/TopBar/CallButton.vue
+++ b/src/components/TopBar/CallButton.vue
@@ -341,6 +341,7 @@ export default {
 
 		isPhoneRoom() {
 			return this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE
+				&& this.conversation.objectId === CONVERSATION.OBJECT_ID.PHONE_OUTGOING
 		},
 
 		isInLobby() {
@@ -407,7 +408,9 @@ export default {
 				const attendeeId = this.$store.getters.participantsList(this.token)
 					.find(participant => participant.actorType === ATTENDEE.ACTOR_TYPE.PHONES)
 					?.attendeeId
-				this.dialOutPhoneNumber(attendeeId)
+				if (attendeeId) {
+					this.dialOutPhoneNumber(attendeeId)
+				}
 			}
 		},
 
diff --git a/src/constants.ts b/src/constants.ts
index d023fd396d4..f0e86d21a74 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -107,6 +107,11 @@ export const CONVERSATION = {
 		DEFAULT: '',
 	},
 
+	OBJECT_ID: {
+		PHONE_INCOMING: 'direct-dialin',
+		PHONE_OUTGOING: 'phone',
+	},
+
 	LIST_STYLE: {
 		TWO_LINES: 'two-lines',
 		COMPACT: 'compact',
diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts
index 697530ce945..c490119705f 100644
--- a/src/types/openapi/openapi-backend-sipbridge.ts
+++ b/src/types/openapi/openapi-backend-sipbridge.ts
@@ -72,6 +72,26 @@ export type paths = {
         patch?: never;
         trace?: never;
     };
+    "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/direct-dial-in": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * Direct dial-in (SIP bridge)
+         * @description Required capability: `sip-direct-dialin`
+         */
+        post: operations["room-direct-dial-in"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/open-dial-in": {
         parameters: {
             query?: never;
@@ -689,6 +709,101 @@ export interface operations {
             };
         };
     };
+    "room-direct-dial-in": {
+        parameters: {
+            query?: never;
+            header: {
+                /** @description Required to be true for the API request to pass */
+                "OCS-APIRequest": boolean;
+            };
+            path: {
+                apiVersion: "v4";
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description Phone number that is called */
+                    phoneNumber: string;
+                    /** @description Phone number of the person calling in */
+                    caller: string;
+                };
+            };
+        };
+        responses: {
+            /** @description Call conversation created */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: components["schemas"]["Room"];
+                        };
+                    };
+                };
+            };
+            /** @description SIP request invalid */
+            401: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+            /** @description Number is not assigned to any user */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+            /** @description Error occurred while creating conversation */
+            500: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+            /** @description SIP dial-in is not configured */
+            501: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+        };
+    };
     "room-create-guest-by-dial-in": {
         parameters: {
             query?: never;
diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts
index f3136d01150..de6fad48c55 100644
--- a/src/types/openapi/openapi-full.ts
+++ b/src/types/openapi/openapi-full.ts
@@ -1926,6 +1926,26 @@ export type paths = {
         patch?: never;
         trace?: never;
     };
+    "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/direct-dial-in": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * Direct dial-in (SIP bridge)
+         * @description Required capability: `sip-direct-dialin`
+         */
+        post: operations["room-direct-dial-in"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/open-dial-in": {
         parameters: {
             query?: never;
@@ -11108,6 +11128,101 @@ export interface operations {
             };
         };
     };
+    "room-direct-dial-in": {
+        parameters: {
+            query?: never;
+            header: {
+                /** @description Required to be true for the API request to pass */
+                "OCS-APIRequest": boolean;
+            };
+            path: {
+                apiVersion: "v4";
+            };
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description Phone number that is called */
+                    phoneNumber: string;
+                    /** @description Phone number of the person calling in */
+                    caller: string;
+                };
+            };
+        };
+        responses: {
+            /** @description Call conversation created */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: components["schemas"]["Room"];
+                        };
+                    };
+                };
+            };
+            /** @description SIP request invalid */
+            401: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+            /** @description Number is not assigned to any user */
+            404: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+            /** @description Error occurred while creating conversation */
+            500: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+            /** @description SIP dial-in is not configured */
+            501: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        ocs: {
+                            meta: components["schemas"]["OCSMeta"];
+                            data: unknown;
+                        };
+                    };
+                };
+            };
+        };
+    };
     "room-create-guest-by-dial-in": {
         parameters: {
             query?: never;
diff --git a/tests/php/Controller/SignalingControllerTest.php b/tests/php/Controller/SignalingControllerTest.php
index 6998d3b128f..dc10a2c85c2 100644
--- a/tests/php/Controller/SignalingControllerTest.php
+++ b/tests/php/Controller/SignalingControllerTest.php
@@ -65,6 +65,7 @@ class SignalingControllerTest extends TestCase {
 	protected \OCA\Talk\Signaling\Manager&MockObject $signalingManager;
 	protected Manager|MockObject $manager;
 	protected ParticipantService&MockObject $participantService;
+	protected RoomService&MockObject $roomService;
 	protected SessionService&MockObject $sessionService;
 	protected Messages&MockObject $messages;
 	protected IUserManager&MockObject $userManager;
@@ -107,6 +108,7 @@ public function setUp(): void {
 		$this->signalingManager = $this->createMock(\OCA\Talk\Signaling\Manager::class);
 		$this->manager = $this->createMock(Manager::class);
 		$this->participantService = $this->createMock(ParticipantService::class);
+		$this->roomService = $this->createMock(RoomService::class);
 		$this->sessionService = $this->createMock(SessionService::class);
 		$this->messages = $this->createMock(Messages::class);
 		$this->timeFactory = $this->createMock(ITimeFactory::class);
@@ -126,6 +128,7 @@ private function recreateSignalingController() {
 			$this->session,
 			$this->manager,
 			$this->participantService,
+			$this->roomService,
 			$this->sessionService,
 			$this->dbConnection,
 			$this->messages,


Reviews:
user: login: fancycode
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T06:52:03Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T14:43:45Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T14:45:26Z
user: login: fancycode
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T14:53:49Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T19:09:07Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T19:36:48Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T19:45:40Z
user: login: fancycode
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T08:15:07Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T11:45:30Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T05:59:18Z
user: login: fancycode
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:32:22Z
user: login: fancycode
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:08:34Z
user: login: fancycode
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:09:05Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T11:34:33Z
user: login: fancycode
body: The triggered dial-out when joining from the web should be fixed imho, the display name is nice to have.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-29T11:53:19Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:11:39Z
user: login: miaulalala
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:31:38Z
user: login: nickvergessen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T13:25:27Z

Review comments:
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
path: lib/Controller/RoomController.php
user: login: fancycode
body: ```suggestion
	 * 404: Participant not found
     * 486: Participant is currently busy
```
created_at: 2025-04-10T06:50:01Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber): DataResponse {
path: lib/Controller/RoomController.php
user: login: fancycode
body: This should also receive the number of the caller so the rooms could be returned depending on who is calling.
created_at: 2025-04-10T06:50:43Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: I'm finding it difficult to see this behaviour without a user option?
It's like "should there be knocking" or not. And I think the answer is "it depends".
Depends on the personal preference, on the person that is calling, and the call I'm currently in.
Should it be a user option?
created_at: 2025-04-14T14:43:45Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber): DataResponse {
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: @fancycode when someone calls with masked number, you send us empty string? or null, or …?
Since the current take is "Reuse room for same number" I kind of need to know when not to look but create a new conversation instead.
created_at: 2025-04-14T14:45:25Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber): DataResponse {
path: lib/Controller/RoomController.php
user: login: fancycode
body: Well, problem is there is no clear definition for that case in SIP. Some providers send "Anonymous" / "anonymous", some send an empty number, etc.

Maybe in a first step you can handle all non-numeric numbers as "anonymous"? In the future there might be cases where this should be supported (e.g. direct calling from "known" devices where the SIP provider might send the name of the user that is calling as display name in the call, e.g. `"John Doe" <sip:1234@asterisk.sipprovider.invalid>`).
created_at: 2025-04-14T14:53:49Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber): DataResponse {
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: Okay, so you don't get to parse that info before, or you don't want to?
We can also `lowercase and in array['anonymous']` with the array being a config administrators can extend, based on user feedback?
created_at: 2025-04-14T19:09:07Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber): DataResponse {
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: okay, for now I will simply try the "phoneNumberUtil::convertToStandardFormat", if that returns an E164 number, we use that as object id, otherwise we use a new room every call.
created_at: 2025-04-14T19:36:48Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: 486 is currently not supported :see_no_evil: 
We can add it to the openapi extractor and the Server APIs if we want, but I'm a bit unsure if it's needed as per above
created_at: 2025-04-14T19:45:40Z
author_association: MEMBER
diff_hunk: @@ -1907,6 +1910,50 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber): DataResponse {
path: lib/Controller/RoomController.php
user: login: fancycode
body: > Okay, so you don't get to parse that info before, or you don't want to? We can also `lowercase and in array['anonymous']` with the array being a config administrators can extend, based on user feedback?

I'd rather not modify the headers on my side, any sanitizing should be done on the receiving side (i.e Talk). Checking the value against an array of "anonymous" values should be fine imho and `['', 'anonymous', 'unknown']` is probably a good default.
created_at: 2025-04-15T08:15:07Z
author_association: MEMBER
diff_hunk: @@ -1923,6 +1926,77 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
+		try {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		$room = null;
+		$cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		if ($cleanedCaller !== null) {
+			$objectId = $cleanedCaller;
+			try {
+				$room = $this->manager->getRoomForUserByObject($entity->getActorId(), Room::OBJECT_TYPE_PHONE, $objectId);
+				$this->logger->debug('Reusing existing direct dial-in conversation for caller ' . $cleanedCaller . ' and user ' . $entity->getActorId());
+			} catch (RoomNotFoundException) {
+			}
+		} else {
+			$objectId = Room::OBJECT_TYPE_PHONE;
+		}
+
+		if (!$room instanceof Room) {
+			$user = $this->userManager->get($entity->getActorId());
+			try {
+				$room = $this->roomService->createConversation(
+					Room::TYPE_GROUP,
+					$caller,
+					$user,
+					Room::OBJECT_TYPE_PHONE,
+					$objectId,
+					sipEnabled: Webinary::SIP_ENABLED_NO_PIN,
+				);
+			} catch (CreationException|PasswordException $e) {
+				$this->logger->error('Error occurred while creating SIP direct dial-in conversation', ['exception' => $e]);
+			}
+		}
+
+		return new DataResponse($this->formatRoom($room, $this->participantService->getParticipant($room, $entity->getActorId(), false)));
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: This should be the new guest not the called person
created_at: 2025-04-15T11:45:30Z
author_association: MEMBER
diff_hunk: @@ -1923,6 +1926,77 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
+		try {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		$room = null;
+		$cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		if ($cleanedCaller !== null) {
+			$objectId = $cleanedCaller;
+			try {
+				$room = $this->manager->getRoomForUserByObject($entity->getActorId(), Room::OBJECT_TYPE_PHONE, $objectId);
+				$this->logger->debug('Reusing existing direct dial-in conversation for caller ' . $cleanedCaller . ' and user ' . $entity->getActorId());
+			} catch (RoomNotFoundException) {
+			}
+		} else {
+			$objectId = Room::OBJECT_TYPE_PHONE;
+		}
+
+		if (!$room instanceof Room) {
+			$user = $this->userManager->get($entity->getActorId());
+			try {
+				$room = $this->roomService->createConversation(
+					Room::TYPE_GROUP,
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: @fancycode can you give it a try if group is okay, or whether we need:
```suggestion
					Room::TYPE_PUBLIC,
```
created_at: 2025-04-16T05:59:18Z
author_association: MEMBER
diff_hunk: @@ -91,6 +91,8 @@
 		['name' => 'Room#verifyDialInPin', 'url' => '/api/{apiVersion}/room/{token}/verify-dialin', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
 		/** @see \OCA\Talk\Controller\RoomController::verifyDialOutNumber() */
 		['name' => 'Room#verifyDialOutNumber', 'url' => '/api/{apiVersion}/room/{token}/verify-dialout', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
+		/** @see \OCA\Talk\Controller\RoomController::directDialIn() */
+		['name' => 'Room#directDialIn', 'url' => '/api/{apiVersion}/room/{token}/direct-dial-in', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
path: appinfo/routes/routesRoomController.php
user: login: fancycode
body: Which `token` should I send here? The room does not exist at the time of the request.
created_at: 2025-04-17T09:32:22Z
author_association: MEMBER
diff_hunk: @@ -1923,6 +1926,77 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
+		try {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		$room = null;
+		$cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		if ($cleanedCaller !== null) {
+			$objectId = $cleanedCaller;
+			try {
+				$room = $this->manager->getRoomForUserByObject($entity->getActorId(), Room::OBJECT_TYPE_PHONE, $objectId);
+				$this->logger->debug('Reusing existing direct dial-in conversation for caller ' . $cleanedCaller . ' and user ' . $entity->getActorId());
+			} catch (RoomNotFoundException) {
+			}
+		} else {
+			$objectId = Room::OBJECT_TYPE_PHONE;
+		}
+
+		if (!$room instanceof Room) {
+			$user = $this->userManager->get($entity->getActorId());
+			try {
+				$room = $this->roomService->createConversation(
+					Room::TYPE_GROUP,
path: lib/Controller/RoomController.php
user: login: fancycode
body: `TYPE_GROUP` works (I can hear the phone in the web and vice versa). Didn't test `TYPE_PUBLIC`.
created_at: 2025-04-17T10:08:34Z
author_association: MEMBER
diff_hunk: @@ -1923,6 +1926,78 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Participant returned
+	 * 401: SIP request invalid
+	 * 404: Participant not found
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	#[RequireRoom]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
+		try {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		$room = null;
+		$cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		if ($cleanedCaller !== null) {
+			$objectId = $cleanedCaller;
+			try {
+				$room = $this->manager->getRoomForUserByObject($entity->getActorId(), Room::OBJECT_TYPE_PHONE, $objectId);
+				$this->logger->debug('Reusing existing direct dial-in conversation for caller ' . $cleanedCaller . ' and user ' . $entity->getActorId());
+			} catch (RoomNotFoundException) {
+			}
+		} else {
+			$objectId = Room::OBJECT_TYPE_PHONE;
+		}
+
+		if (!$room instanceof Room) {
+			$user = $this->userManager->get($entity->getActorId());
+			try {
+				$room = $this->roomService->createConversation(
+					Room::TYPE_GROUP,
+					$caller,
+					$user,
+					Room::OBJECT_TYPE_PHONE,
+					$objectId,
+					sipEnabled: Webinary::SIP_ENABLED_NO_PIN,
+				);
+			} catch (CreationException|PasswordException $e) {
+				$this->logger->error('Error occurred while creating SIP direct dial-in conversation', ['exception' => $e]);
+			}
+		}
+
+		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $this->room, '', true);
path: lib/Controller/RoomController.php
user: login: fancycode
body: ```suggestion
		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, '', true);
```
created_at: 2025-04-17T10:09:05Z
author_association: MEMBER
diff_hunk: @@ -91,6 +91,8 @@
 		['name' => 'Room#verifyDialInPin', 'url' => '/api/{apiVersion}/room/{token}/verify-dialin', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
 		/** @see \OCA\Talk\Controller\RoomController::verifyDialOutNumber() */
 		['name' => 'Room#verifyDialOutNumber', 'url' => '/api/{apiVersion}/room/{token}/verify-dialout', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
+		/** @see \OCA\Talk\Controller\RoomController::directDialIn() */
+		['name' => 'Room#directDialIn', 'url' => '/api/{apiVersion}/room/{token}/direct-dial-in', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
path: appinfo/routes/routesRoomController.php
user: login: nickvergessen
body: that's a correct oversight. Should be a URL without a token
created_at: 2025-04-17T11:34:33Z
author_association: MEMBER
diff_hunk: @@ -1963,6 +1970,67 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * Required capability: `sip-direct-dialin`
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Call conversation created
+	 * 401: SIP request invalid
+	 * 404: Number is not assigned to any user
+	 * 500: Error occurred while creating conversation
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
+		try {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		// TODO: Use later and get name from addressbook? $cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		$user = $this->userManager->get($entity->getActorId());
+		try {
+			$room = $this->roomService->createConversation(
+				Room::TYPE_GROUP,
+				$caller,
+				$user,
+				Room::OBJECT_TYPE_PHONE,
+				Room::OBJECT_ID_PHONE_INCOMING,
+				sipEnabled: Webinary::SIP_ENABLED_NO_PIN,
+			);
+		} catch (CreationException|PasswordException $e) {
+			$this->logger->error('Error occurred while creating SIP direct dial-in conversation', ['exception' => $e]);
+			return new DataResponse(null, Http::STATUS_INTERNAL_SERVER_ERROR);
+		}
+
+		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, '', true);
path: lib/Controller/RoomController.php
user: login: fancycode
body: Maybe also set the display name of the participant to the (anonymized) number of the caller?
created_at: 2025-04-29T11:28:25Z
author_association: MEMBER
diff_hunk: @@ -1963,6 +1970,67 @@ public function verifyDialInPin(string $pin): DataResponse {
 		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
 	}
 
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * Required capability: `sip-direct-dialin`
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Call conversation created
+	 * 401: SIP request invalid
+	 * 404: Number is not assigned to any user
+	 * 500: Error occurred while creating conversation
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
+		try {
+			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
+				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+				$response->throttle(['action' => 'talkSipBridgeSecret']);
+				return $response;
+			}
+		} catch (UnauthorizedException) {
+			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
+			$response->throttle(['action' => 'talkSipBridgeSecret']);
+			return $response;
+		}
+
+		if (!$this->talkConfig->isSIPConfigured()) {
+			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
+		}
+
+		try {
+			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
+		} catch (DoesNotExistException) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		// TODO: Use later and get name from addressbook? $cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
+		$user = $this->userManager->get($entity->getActorId());
+		try {
+			$room = $this->roomService->createConversation(
+				Room::TYPE_GROUP,
+				$caller,
+				$user,
+				Room::OBJECT_TYPE_PHONE,
+				Room::OBJECT_ID_PHONE_INCOMING,
+				sipEnabled: Webinary::SIP_ENABLED_NO_PIN,
+			);
+		} catch (CreationException|PasswordException $e) {
+			$this->logger->error('Error occurred while creating SIP direct dial-in conversation', ['exception' => $e]);
+			return new DataResponse(null, Http::STATUS_INTERNAL_SERVER_ERROR);
+		}
+
+		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, '', true);
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: Fixed
created_at: 2025-04-29T13:11:39Z
author_association: MEMBER
diff_hunk: @@ -1950,17 +1961,75 @@ public function verifyDialInPin(string $pin): DataResponse {
 			return $response;
 		}
 
+		try {
+			$participant = $this->participantService->getParticipantByPin($this->room, $pin);
+		} catch (ParticipantNotFoundException $e) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
+	}
+
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * Required capability: `sip-direct-dialin`
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Call conversation created
+	 * 401: SIP request invalid
+	 * 404: Number is not assigned to any user
+	 * 500: Error occurred while creating conversation
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
 		if (!$this->talkConfig->isSIPConfigured()) {
 			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
path: lib/Controller/RoomController.php
user: login: miaulalala
body: should we also throttle the "not implemented" response?
created_at: 2025-04-30T12:27:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1950,17 +1961,75 @@ public function verifyDialInPin(string $pin): DataResponse {
 			return $response;
 		}
 
+		try {
+			$participant = $this->participantService->getParticipantByPin($this->room, $pin);
+		} catch (ParticipantNotFoundException $e) {
+			return new DataResponse(null, Http::STATUS_NOT_FOUND);
+		}
+
+		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
+	}
+
+	/**
+	 * Direct dial-in (SIP bridge)
+	 *
+	 * Required capability: `sip-direct-dialin`
+	 *
+	 * @param string $phoneNumber Phone number that is called
+	 * @param string $caller Phone number of the person calling in
+	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
+	 *
+	 * 200: Call conversation created
+	 * 401: SIP request invalid
+	 * 404: Number is not assigned to any user
+	 * 500: Error occurred while creating conversation
+	 * 501: SIP dial-in is not configured
+	 */
+	#[PublicPage]
+	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
+	#[OpenAPI(scope: 'backend-sipbridge')]
+	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
 		if (!$this->talkConfig->isSIPConfigured()) {
 			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
path: lib/Controller/RoomController.php
user: login: nickvergessen
body: you are not able to brute force the sip secret, so I don't think it's needed
created_at: 2025-04-30T13:25:27Z
author_association: MEMBER

PR comments:
user: login: nickvergessen
created_at: 2025-04-14T14:52:07Z
body: /backport to stable31
author_association: MEMBER
user: login: fancycode
created_at: 2025-04-17T10:13:25Z
body: On a more general note: some customers might use internal phone extensions, i.e. not fully E.164 formatted. This will cause the validation to fail both here and in #14729. In such cases, numbers will be short.

I would suggest supporting these both in #14729 and here, i.e. everything that is all-numeric (with an optional leading `+`) will be considered to be a phone number. This then matches both short internal extensions and full E.164 numbers.
author_association: MEMBER
user: login: fancycode
created_at: 2025-04-17T10:28:47Z
body: We probably need a dedicated response code for when the regular call workflow should be used, or should that always happen on `404`? 
author_association: MEMBER
user: login: nickvergessen
created_at: 2025-04-22T08:35:13Z
body: > or should that always happen on 404?

I'm not really sure I can distinguish a random number from a workflow one. So yeah I'd say 404 means there is no direct-dialin => normal workflow?

author_association: MEMBER
user: login: nickvergessen
created_at: 2025-04-28T14:04:51Z
body: - [x] Conversation is not marked as "In call" yet
- [ ] Neither does the called user receive a notification
author_association: MEMBER
user: login: nickvergessen
created_at: 2025-04-28T15:09:03Z
body: /backport 2dad1f76b8a17fea4eeb9e980926b57768697cca to stable30
author_association: MEMBER
user: login: nickvergessen
created_at: 2025-04-28T15:09:16Z
body: /backport 2dad1f76b8a17fea4eeb9e980926b57768697cca to stable31.0
author_association: MEMBER
user: login: fancycode
created_at: 2025-04-29T11:26:14Z
body: Web-UI tries to call the phone user when clicking the button to join the call.

POST-URL is `https://myserver/ocs/v2.php/apps/spreed/api/v4/call/<token>/dialout/%7BattendeeId%7D`.
author_association: MEMBER
user: login: nickvergessen
created_at: 2025-04-29T13:12:01Z
body: > The triggered dial-out when joining from the web should be fixed imho, the display name is nice to have.

It was done from frontend, but fixed it myself
author_association: MEMBER