Deploy Ladill Meet / deploy (push) Successful in 1m1s
Queue summarization on session end (with a short delay for late captions), keep recording-ready as a refresh path, and unique the summary job so end plus recording do not double-process the same transcript.
478 lines
16 KiB
PHP
478 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Meet;
|
|
|
|
use App\Models\Participant;
|
|
use App\Models\Room;
|
|
use App\Models\Session;
|
|
use App\Models\User;
|
|
use App\Services\Meet\Media\MediaProviderInterface;
|
|
use App\Support\EventsRegistrationSession;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Str;
|
|
|
|
class SessionService
|
|
{
|
|
public function __construct(
|
|
protected MediaProviderInterface $media,
|
|
protected RoomService $rooms,
|
|
) {}
|
|
|
|
public function start(Room $room, User $host): Session
|
|
{
|
|
$existing = $room->activeSession();
|
|
if ($existing) {
|
|
return $existing;
|
|
}
|
|
|
|
app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id, $room);
|
|
|
|
$session = Session::create([
|
|
'owner_ref' => $room->owner_ref,
|
|
'room_id' => $room->id,
|
|
'media_room_name' => $this->rooms->mediaRoomName($room),
|
|
'status' => 'live',
|
|
'started_at' => now(),
|
|
]);
|
|
|
|
$room->update(['status' => 'live']);
|
|
|
|
$this->media->createRoom($session->media_room_name);
|
|
|
|
$this->joinParticipant($session, $host, 'host');
|
|
|
|
if ($room->setting('auto_record', false)) {
|
|
app(RecordingService::class)->start($session, $host);
|
|
}
|
|
|
|
app(MeetNotificationService::class)->meetingStarted($session, $host);
|
|
|
|
app(\App\Services\Integrations\WebhookDispatcher::class)->meetingStarted($session);
|
|
|
|
AuditLogger::record($room->owner_ref, 'session.started', $room->organization_id, $host->ownerRef(), Session::class, $session->id);
|
|
|
|
return $session;
|
|
}
|
|
|
|
public function end(Session $session, string $actorRef): Session
|
|
{
|
|
$session->update([
|
|
'status' => 'ended',
|
|
'ended_at' => now(),
|
|
]);
|
|
|
|
$session->room->update(['status' => 'ended']);
|
|
|
|
$session->participants()
|
|
->whereIn('status', ['joined', 'waiting'])
|
|
->update(['status' => 'left', 'left_at' => now()]);
|
|
|
|
foreach ($session->recordings()->where('status', 'recording')->get() as $recording) {
|
|
app(RecordingService::class)->stop($recording, $actorRef);
|
|
}
|
|
|
|
if ($session->room->isRecurring()) {
|
|
app(RecurringMeetingService::class)->spawnNextOccurrences($session->room, 1);
|
|
}
|
|
|
|
AuditLogger::record($session->owner_ref, 'session.ended', $session->room->organization_id, $actorRef, Session::class, $session->id);
|
|
|
|
app(\App\Services\Integrations\WebhookDispatcher::class)->meetingEnded($session);
|
|
|
|
app(UsageMeteringService::class)->recordSessionUsage($session);
|
|
|
|
// Summarize on meeting end (not only when a recording is ready). Short delay
|
|
// lets the last caption/chat POSTs land before the transcript is finalized.
|
|
$session->loadMissing('room');
|
|
$transcripts = app(TranscriptService::class);
|
|
if ($transcripts->shouldAutoSummarize($session)) {
|
|
$transcripts->finalizeAndSummarize($session->fresh(), null, 20);
|
|
}
|
|
|
|
return $session->fresh();
|
|
}
|
|
|
|
public function joinParticipant(
|
|
Session $session,
|
|
?User $user,
|
|
string $role,
|
|
?string $displayName = null,
|
|
?string $email = null,
|
|
string $status = 'joined',
|
|
): Participant {
|
|
$userRef = $user?->ownerRef();
|
|
$existing = $this->findExistingParticipant($session, $userRef, $email);
|
|
|
|
if ($existing) {
|
|
$updates = [
|
|
'left_at' => null,
|
|
'display_name' => $displayName ?? $existing->display_name,
|
|
'ip_address' => request()?->ip(),
|
|
];
|
|
|
|
if ($existing->status === 'waiting') {
|
|
$updates['status'] = 'waiting';
|
|
} else {
|
|
$updates['status'] = 'joined';
|
|
$updates['joined_at'] = $existing->joined_at ?? now();
|
|
}
|
|
|
|
if ($this->shouldPromoteParticipantRole($existing->role, $role)) {
|
|
$updates['role'] = $role;
|
|
}
|
|
|
|
$existing->update($updates);
|
|
|
|
return $existing->fresh();
|
|
}
|
|
|
|
$joinedAt = $status === 'joined' ? now() : null;
|
|
|
|
$participant = Participant::create([
|
|
'owner_ref' => $session->owner_ref,
|
|
'session_id' => $session->id,
|
|
'user_ref' => $userRef,
|
|
'display_name' => $displayName ?? $user?->name ?? 'Guest',
|
|
'email' => $email,
|
|
'role' => $role,
|
|
'status' => $status,
|
|
'joined_at' => $joinedAt,
|
|
'ip_address' => request()?->ip(),
|
|
]);
|
|
|
|
if ($status === 'joined') {
|
|
$this->updatePeakParticipants($session);
|
|
}
|
|
|
|
if ($status === 'joined') {
|
|
AuditLogger::record(
|
|
$session->owner_ref,
|
|
'participant.joined',
|
|
$session->room->organization_id,
|
|
$userRef,
|
|
Participant::class,
|
|
$participant->id,
|
|
);
|
|
}
|
|
|
|
return $participant;
|
|
}
|
|
|
|
public function admitParticipant(Participant $participant): Participant
|
|
{
|
|
abort_unless($participant->status === 'waiting', 422);
|
|
|
|
$participant->update([
|
|
'status' => 'joined',
|
|
'joined_at' => now(),
|
|
'left_at' => null,
|
|
]);
|
|
|
|
$this->updatePeakParticipants($participant->session);
|
|
|
|
AuditLogger::record(
|
|
$participant->owner_ref,
|
|
'participant.joined',
|
|
$participant->session->room->organization_id,
|
|
$participant->user_ref,
|
|
Participant::class,
|
|
$participant->id,
|
|
);
|
|
|
|
return $participant->fresh();
|
|
}
|
|
|
|
public function denyParticipant(Participant $participant): Participant
|
|
{
|
|
abort_unless($participant->status === 'waiting', 422);
|
|
|
|
$participant->update([
|
|
'status' => 'removed',
|
|
'left_at' => now(),
|
|
]);
|
|
|
|
AuditLogger::record(
|
|
$participant->owner_ref,
|
|
'participant.removed',
|
|
$participant->session->room->organization_id,
|
|
$participant->user_ref,
|
|
Participant::class,
|
|
$participant->id,
|
|
);
|
|
|
|
return $participant->fresh();
|
|
}
|
|
|
|
public function syncEventsSpeakerRole(Request $request, Participant $participant): Participant
|
|
{
|
|
$participant->loadMissing('session.room');
|
|
$room = $participant->session->room;
|
|
|
|
if (! $room->isEventsLinked() || ! EventsRegistrationSession::isSpeakerVerified($request, $room)) {
|
|
return $participant;
|
|
}
|
|
|
|
return $this->promoteParticipantRole($participant, 'panelist');
|
|
}
|
|
|
|
private function promoteParticipantRole(Participant $participant, string $role): Participant
|
|
{
|
|
if ($this->shouldPromoteParticipantRole($participant->role, $role)) {
|
|
$participant->update(['role' => $role]);
|
|
|
|
return $participant->fresh();
|
|
}
|
|
|
|
return $participant;
|
|
}
|
|
|
|
private function shouldPromoteParticipantRole(string $currentRole, string $newRole): bool
|
|
{
|
|
static $rank = [
|
|
'guest' => 10,
|
|
'attendee' => 20,
|
|
'participant' => 25,
|
|
'panelist' => 40,
|
|
'co_host' => 50,
|
|
'host' => 60,
|
|
];
|
|
|
|
return ($rank[$newRole] ?? 0) > ($rank[$currentRole] ?? 0);
|
|
}
|
|
|
|
protected function findExistingParticipant(Session $session, ?string $userRef, ?string $email): ?Participant
|
|
{
|
|
$active = fn ($q) => $q->whereIn('status', ['invited', 'waiting', 'joined']);
|
|
|
|
if ($userRef) {
|
|
return $session->participants()
|
|
->where('user_ref', $userRef)
|
|
->where($active)
|
|
->first();
|
|
}
|
|
|
|
if ($email) {
|
|
return $session->participants()
|
|
->whereNull('user_ref')
|
|
->where('email', $email)
|
|
->where($active)
|
|
->first();
|
|
}
|
|
|
|
$guestUuid = (string) request()?->session()?->get("meet.participant.{$session->uuid}", '');
|
|
if ($guestUuid === '') {
|
|
return null;
|
|
}
|
|
|
|
return $session->participants()
|
|
->where('uuid', $guestUuid)
|
|
->whereNull('user_ref')
|
|
->where($active)
|
|
->first();
|
|
}
|
|
|
|
public function rememberParticipantSession(Request $request, Participant $participant, ?Session $session = null): void
|
|
{
|
|
$session ??= $participant->session;
|
|
$request->session()->put("meet.participant.{$session->uuid}", $participant->uuid);
|
|
}
|
|
|
|
public function ensureJoinedParticipant(
|
|
Request $request,
|
|
Session $session,
|
|
?User $user,
|
|
string $role,
|
|
?string $displayName = null,
|
|
?string $email = null,
|
|
): Participant {
|
|
if ($user) {
|
|
$existing = $this->joinedParticipantForUser($session, $user);
|
|
if ($existing) {
|
|
$existing = $this->promoteParticipantRole($existing, $role);
|
|
$this->rememberParticipantSession($request, $existing, $session);
|
|
|
|
return $existing;
|
|
}
|
|
}
|
|
|
|
$participant = $this->joinParticipant($session, $user, $role, $displayName, $email);
|
|
$this->rememberParticipantSession($request, $participant, $session);
|
|
|
|
return $participant;
|
|
}
|
|
|
|
public function transitionGreenRoomParticipant(
|
|
Request $request,
|
|
Participant $greenParticipant,
|
|
Session $liveSession,
|
|
): ?Participant {
|
|
$presenters = (array) $liveSession->presenter_refs;
|
|
$isPresenter = in_array($greenParticipant->role, ['host', 'co_host', 'panelist'], true)
|
|
|| ($greenParticipant->user_ref && in_array($greenParticipant->user_ref, $presenters, true));
|
|
|
|
if (! $isPresenter) {
|
|
return null;
|
|
}
|
|
|
|
$user = $greenParticipant->user_ref
|
|
? User::where('public_id', $greenParticipant->user_ref)->first()
|
|
: null;
|
|
|
|
$role = $greenParticipant->role;
|
|
if ($user && $user->ownerRef() === $liveSession->room->host_user_ref) {
|
|
$role = 'host';
|
|
} elseif (! in_array($role, ['host', 'co_host', 'panelist'], true)) {
|
|
$role = 'panelist';
|
|
}
|
|
|
|
$greenSession = $greenParticipant->session;
|
|
$participant = $this->ensureJoinedParticipant(
|
|
$request,
|
|
$liveSession,
|
|
$user,
|
|
$role,
|
|
$greenParticipant->display_name,
|
|
$greenParticipant->email,
|
|
);
|
|
|
|
$request->session()->forget("meet.participant.{$greenSession->uuid}");
|
|
|
|
return $participant;
|
|
}
|
|
|
|
public function joinedParticipantForUser(Session $session, User $user): ?Participant
|
|
{
|
|
return $session->participants()
|
|
->where('user_ref', $user->ownerRef())
|
|
->whereIn('status', ['invited', 'waiting', 'joined'])
|
|
->first();
|
|
}
|
|
|
|
public function leaveParticipant(Participant $participant): Participant
|
|
{
|
|
$participant->update([
|
|
'status' => 'left',
|
|
'left_at' => now(),
|
|
'hand_raised' => false,
|
|
]);
|
|
|
|
AuditLogger::record(
|
|
$participant->owner_ref,
|
|
'participant.left',
|
|
$participant->session->room->organization_id,
|
|
$participant->user_ref,
|
|
Participant::class,
|
|
$participant->id,
|
|
);
|
|
|
|
return $participant;
|
|
}
|
|
|
|
public function generateMediaToken(Participant $participant): string
|
|
{
|
|
$participant->loadMissing('breakoutRoom');
|
|
$state = $this->mediaSessionState($participant);
|
|
$mediaRoom = app(BreakoutService::class)->mediaRoomForParticipant($participant);
|
|
|
|
return $this->media->generateToken(
|
|
$mediaRoom,
|
|
$participant->uuid,
|
|
$participant->display_name,
|
|
[
|
|
'can_publish' => $state['can_publish'],
|
|
'can_subscribe' => true,
|
|
'can_publish_data' => true,
|
|
'room_admin' => $participant->isHost(),
|
|
],
|
|
);
|
|
}
|
|
|
|
/** @return array{can_publish: bool, audio_only_publish: bool, speak_granted: bool, speak_requested: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
|
|
public function mediaSessionState(Participant $participant): array
|
|
{
|
|
$participant->loadMissing(['breakoutRoom', 'session.room']);
|
|
$room = $participant->session->room;
|
|
$breakoutService = app(BreakoutService::class);
|
|
$inBreakout = $breakoutService->isInActiveBreakout($participant);
|
|
$usesStageLayout = app(StageLayoutService::class)->usesStageLayout($room) && ! $inBreakout;
|
|
$canPublish = $this->canParticipantPublish($participant, $inBreakout);
|
|
$audioOnlyPublish = $canPublish
|
|
&& $participant->isAttendee()
|
|
&& (bool) $participant->speak_granted
|
|
&& ! $inBreakout;
|
|
|
|
$breakout = null;
|
|
if ($inBreakout && $participant->breakoutRoom) {
|
|
$breakout = [
|
|
'uuid' => $participant->breakoutRoom->uuid,
|
|
'name' => $participant->breakoutRoom->name,
|
|
'closes_at' => $participant->breakoutRoom->closes_at?->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'can_publish' => $canPublish,
|
|
'audio_only_publish' => $audioOnlyPublish,
|
|
'speak_granted' => (bool) $participant->speak_granted,
|
|
'speak_requested' => (bool) $participant->speak_requested,
|
|
'uses_stage_layout' => $usesStageLayout,
|
|
'in_breakout' => $inBreakout,
|
|
'breakout_room_id' => $inBreakout ? $participant->breakout_room_id : null,
|
|
'breakout' => $breakout,
|
|
];
|
|
}
|
|
|
|
public function canParticipantPublish(Participant $participant, ?bool $inBreakout = null): bool
|
|
{
|
|
$breakoutService = app(BreakoutService::class);
|
|
$inBreakout ??= $breakoutService->isInActiveBreakout($participant);
|
|
|
|
if ($inBreakout) {
|
|
return true;
|
|
}
|
|
|
|
$room = $participant->session->room;
|
|
|
|
if ($room->isSpace()) {
|
|
return app(\App\Services\Meet\SpaceService::class)->canPublish($room, $participant)
|
|
&& $participant->canPublishMedia();
|
|
}
|
|
|
|
return app(WebinarService::class)->canPublish($room, $participant)
|
|
&& $participant->canPublishMedia();
|
|
}
|
|
|
|
public function getOrStartSession(Room $room, User $host): Session
|
|
{
|
|
if ($room->activeSession()) {
|
|
return $room->activeSession();
|
|
}
|
|
|
|
return $this->start($room, $host);
|
|
}
|
|
|
|
public function lock(Session $session, string $actorRef): Session
|
|
{
|
|
$session->update(['is_locked' => true, 'locked_at' => now()]);
|
|
AuditLogger::record($session->owner_ref, 'meeting.locked', $session->room->organization_id, $actorRef, Session::class, $session->id);
|
|
|
|
return $session;
|
|
}
|
|
|
|
public function unlock(Session $session, string $actorRef): Session
|
|
{
|
|
$session->update(['is_locked' => false, 'locked_at' => null]);
|
|
AuditLogger::record($session->owner_ref, 'meeting.unlocked', $session->room->organization_id, $actorRef, Session::class, $session->id);
|
|
|
|
return $session;
|
|
}
|
|
|
|
protected function updatePeakParticipants(Session $session): void
|
|
{
|
|
$count = $session->participants()->where('status', 'joined')->count();
|
|
if ($count > $session->peak_participants) {
|
|
$session->update(['peak_participants' => $count]);
|
|
}
|
|
}
|
|
}
|